From aecdf422037f3a0c3f97052d04c9331fe944427d Mon Sep 17 00:00:00 2001 From: Marcelo Nascimento Date: Sun, 27 Sep 2026 11:55:56 +0100 Subject: [PATCH 1/5] chore: run on Vercel Hobby with external mailbox sync Daily crons in apps/api/vercel.json fit the Hobby plan. A scheduled GitHub Actions workflow triggers /internal/sync/mailboxes every 10 minutes with CRON_SECRET. Adds the OpenSpec changes for the production deploy and the proposal generator. --- .github/workflows/sync-mailboxes.yml | 33 +++++++++ apps/api/vercel.json | 2 +- .../deploy-vercel-producao/.openspec.yaml | 2 + .../changes/deploy-vercel-producao/design.md | 72 +++++++++++++++++++ .../deploy-vercel-producao/proposal.md | 29 ++++++++ .../specs/operacao-producao/spec.md | 63 ++++++++++++++++ .../changes/deploy-vercel-producao/tasks.md | 58 +++++++++++++++ .../gerador-de-propostas/.openspec.yaml | 2 + .../changes/gerador-de-propostas/proposal.md | 31 ++++++++ openspec/config.yaml | 28 ++++++++ 10 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sync-mailboxes.yml create mode 100644 openspec/changes/deploy-vercel-producao/.openspec.yaml create mode 100644 openspec/changes/deploy-vercel-producao/design.md create mode 100644 openspec/changes/deploy-vercel-producao/proposal.md create mode 100644 openspec/changes/deploy-vercel-producao/specs/operacao-producao/spec.md create mode 100644 openspec/changes/deploy-vercel-producao/tasks.md create mode 100644 openspec/changes/gerador-de-propostas/.openspec.yaml create mode 100644 openspec/changes/gerador-de-propostas/proposal.md create mode 100644 openspec/config.yaml diff --git a/.github/workflows/sync-mailboxes.yml b/.github/workflows/sync-mailboxes.yml new file mode 100644 index 000000000..372bc505d --- /dev/null +++ b/.github/workflows/sync-mailboxes.yml @@ -0,0 +1,33 @@ +name: Sync mailboxes + +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: + +concurrency: + group: sync-mailboxes + cancel-in-progress: false + +permissions: {} + +jobs: + sync: + name: POST /internal/sync/mailboxes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Trigger mailbox sync + env: + API_URL: ${{ vars.API_URL }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ -z "$API_URL" ] || [ -z "$CRON_SECRET" ]; then + echo "::error::API_URL (variable) and CRON_SECRET (secret) must both be set in Settings → Secrets and variables → Actions." + exit 1 + fi + curl --fail-with-body --silent --show-error \ + --max-time 240 \ + --request POST \ + --header "Authorization: Bearer $CRON_SECRET" \ + "${API_URL%/}/internal/sync/mailboxes" diff --git a/apps/api/vercel.json b/apps/api/vercel.json index ffce1ccc0..2f6c5712a 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -3,7 +3,7 @@ "crons": [ { "path": "/internal/sync/mailboxes", - "schedule": "*/5 * * * *" + "schedule": "0 3 * * *" }, { "path": "/internal/sync/rates", diff --git a/openspec/changes/deploy-vercel-producao/.openspec.yaml b/openspec/changes/deploy-vercel-producao/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/deploy-vercel-producao/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/deploy-vercel-producao/design.md b/openspec/changes/deploy-vercel-producao/design.md new file mode 100644 index 000000000..449243037 --- /dev/null +++ b/openspec/changes/deploy-vercel-producao/design.md @@ -0,0 +1,72 @@ +## Context + +O repositório é um monorepo Turborepo/Bun com três processos (`apps/app` Next.js, `apps/api` NestJS, `apps/agent` eve) e um Postgres. O projeto original assume Vercel: crons em `apps/api/vercel.json`, migrations aplicadas no build quando `VERCEL_ENV=production`, IA do agente via Vercel AI Gateway com OIDC. Não há Dockerfiles. + +Restrições que moldam o desenho: +- **Plano Hobby**: crons executam no máximo uma vez por dia; o repositório define `*/5 * * * *` para caixas de e-mail e o agente (`apps/agent/agent/schedules/dispatch.ts`) roda `* * * * *`. +- **App e API precisam concordar** em `DATABASE_URL` e `BETTER_AUTH_SECRET`; a API emite o cookie de sessão e o app o verifica. +- **Google OAuth** em modo Teste para contas `@gmail.com` (sem Workspace); tokens de atualização expiram em 7 dias. + +## Goals / Non-Goals + +**Goals:** +- App e API em produção, com custo de hospedagem zero. +- Um caminho claro para receber atualizações do `upstream` sem perder as adaptações. + +**Non-Goals:** +- Publicar o agente nesta etapa sem validação prévia (fica condicionado ao spike). +- Sair do modo Teste do Google (verificação/CASA) ou adotar Google Workspace. +- Ambiente de staging/preview com banco próprio. + +## Decisions + +### D1 — Fork no GitHub como origem, `trycompai/crm` como `upstream` +Deploys da Vercel saem da branch `release` do fork (Production Branch na Vercel = `release`). Atualizações entram por `git fetch upstream && git merge upstream/release`. +- *Alternativa*: branch `main` do fork. Descartada: o `release.yml` herdado roda a cada push em `main` e abre PRs de versão do release-please no fork; `release` dispara apenas o `ci.yml`. +- *Alternativa*: cópia sem vínculo (novo repositório). Descartada: perde o caminho de atualização. +- Workflows herdados que não se aplicam ao fork (`auto-pr`, `release`, `pr-base`, `pr-title`) são desativados pela interface do GitHub (Actions → workflow → Disable), sem editar os arquivos, para não criar conflitos com o `upstream`. + +### D2 — Dois projetos Vercel, um por app, com Root Directory em `apps/app` e `apps/api` +É o modelo que o projeto original já espera (um `vercel.json` por app). Domínios: `crm.guardon.me` → app, `api.guardon.me` → API. `AUTH_COOKIE_DOMAIN=.guardon.me` para uma sessão cobrir ambos. +- *Alternativa*: API servida sob o mesmo domínio do app via rewrites. Descartada: diverge do upstream e complica `/api/auth/*`. + +### D3 — Neon Free via Vercel Marketplace +Integração injeta `DATABASE_URL` (pooled) e `POSTGRES_URL_NON_POOLING`; o build usa a conexão direta para `prisma migrate deploy`. 0,5 GB atende 3–4 usuários. +- *Alternativa*: Supabase Free. Equivalente; Neon escolhido pela integração nativa e pelo suporte explícito a `POSTGRES_URL_NON_POOLING` no `.env.example`. + +### D4 — Crons diárias na Vercel + GitHub Actions para caixas de e-mail +`apps/api/vercel.json` passa a ter todas as crons diárias (compatível com Hobby). Um workflow agendado no fork chama `GET https://api.guardon.me/internal/sync/mailboxes` com `Authorization: Bearer $CRON_SECRET` a cada 10 minutos. As rotas já aceitam GET e POST e já validam `CRON_SECRET` em tempo constante. +- *Alternativa*: cron-job.org. Viável, mas coloca o segredo em mais um serviço externo; GitHub Actions mantém tudo no fork. +- *Trade-off*: o agendamento do GitHub Actions atrasa em horários de pico e é pausado após 60 dias sem atividade no repositório. + +### D5 — Agente condicionado a spike +Antes de publicar `apps/agent`, verificar: (a) como o eve mapeia `defineSchedule({ cron: "* * * * *" })` na Vercel e se o Hobby rejeita o deploy; (b) se o disparo externo (`/internal/crm/dispatch`, já usado pelo "poke" da API com `AGENT_BRIDGE_SECRET`) substitui o agendamento por minuto; (c) custo/limite do sandbox e do AI Gateway no Hobby. O CRM já degrada sem o agente (ver spec, requisito "Funcionamento sem o agente"). + +### D6 — `ALLOWED_SIGN_IN` com e-mails individuais +Sem domínio próprio no Google, a lista contém os endereços da equipe separados por vírgula. Nunca `gmail.com` inteiro. + +## Risks / Trade-offs + +- [Sessão Google expira a cada 7 dias em modo Teste] → Aceito; documentar para a equipe. Saída futura: Google Workspace em `guardon.me` com app Interno. +- [Termos do Hobby preveem uso não comercial] → Aceito conscientemente para avaliação; reavaliar o Pro quando o uso se consolidar. +- [Mismatch de `BETTER_AUTH_SECRET` entre app e API causa loop de redirecionamento] → Definir o valor uma vez e conferir nos dois projetos antes do primeiro login. +- [Merge do `upstream` conflitar em `apps/api/vercel.json`] → Mudança pequena e isolada; resolver manualmente mantendo frequências diárias. +- [GitHub Actions pausa agendamentos em repositório inativo] → Um commit ou reativação manual a cada 60 dias; monitorar pela aba Actions. +- [`.env` local contém segredos] → Nunca commitar; segredos de produção gerados novamente, não reaproveitados do ambiente local. + +## Migration Plan + +1. Fork e remotes; push da branch de trabalho. +2. Neon + projeto Vercel da API → deploy → migrations aplicadas no build. +3. Projeto Vercel do app → deploy. +4. DNS e domínios; variáveis de URL; redeploy. +5. OAuth do Google com URIs de produção. +6. Workflow do GitHub Actions + `CRON_SECRET`. +7. Teste de login com cada membro; teste de sincronização. + +Rollback: sem dados legados em produção; em caso de falha basta reverter o deploy na Vercel (instant rollback) ou remover os domínios. O ambiente local continua intacto. + +## Open Questions + +- Frequência exata do workflow de e-mail (10 vs 15 min) — ajustável sem alterar a spec. +- Se o repositório fork será público ou privado (Actions em repositório privado consome minutos gratuitos; o uso estimado cabe na cota). diff --git a/openspec/changes/deploy-vercel-producao/proposal.md b/openspec/changes/deploy-vercel-producao/proposal.md new file mode 100644 index 000000000..9ba406921 --- /dev/null +++ b/openspec/changes/deploy-vercel-producao/proposal.md @@ -0,0 +1,29 @@ +## Why + +O CRM roda apenas em `localhost`, na máquina de um único usuário. Para ser a ferramenta de vendas da guardon.me no dia a dia, ele precisa estar acessível na internet para 3–4 pessoas, com login restrito, dados persistentes e sincronização de e-mail funcionando — e, por decisão de custo, dentro do plano gratuito (Hobby) da Vercel enquanto o uso é avaliado. + +## What Changes + +- Criar um repositório próprio no GitHub (fork de `trycompai/crm`) como origem dos deploys, mantendo `trycompai/crm` como `upstream` para receber atualizações. +- Provisionar Postgres gerenciado (Neon Free, via Vercel Marketplace) e aplicar as migrations em produção. +- Publicar `apps/app` e `apps/api` como dois projetos Vercel sob `crm.guardon.me` e `api.guardon.me`, compartilhando sessão via `AUTH_COOKIE_DOMAIN=.guardon.me`. +- Adaptar as crons de `apps/api/vercel.json` ao limite do plano Hobby (execução no máximo diária) e mover a sincronização de caixas de e-mail para um agendador externo gratuito (GitHub Actions) que chama `/internal/sync/mailboxes` com `CRON_SECRET`. +- Configurar o OAuth do Google para produção (origem e URI de redirecionamento de `api.guardon.me`), mantendo o app em modo **Teste** com os e-mails da equipe como usuários de teste. +- Restringir o acesso via `ALLOWED_SIGN_IN` à lista explícita de e-mails da equipe. +- Publicar `apps/agent` somente após uma investigação (spike) confirmar que o agendamento por minuto do eve e o sandbox cabem no plano Hobby; até lá o CRM opera sem o agente, que é opcional por desenho. + +## Capabilities + +### New Capabilities +- `operacao-producao`: requisitos operacionais do ambiente de produção — acesso restrito, sessão entre subdomínios, agendamento de tarefas periódicas dentro do plano gratuito e comportamento do CRM com o agente ausente. + +### Modified Capabilities + + +## Impact + +- **Código**: `apps/api/vercel.json` (frequência das crons); novo workflow em `.github/workflows/` para a sincronização de e-mail. Nenhuma mudança em lógica de aplicação. +- **Infraestrutura**: 2 projetos Vercel (3 se o agente for aprovado no spike), banco Neon, DNS de `guardon.me`, projeto `MyCRM` no Google Cloud. +- **Segredos**: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `CRON_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `ALLOWED_SIGN_IN`, `AGENT_BRIDGE_SECRET` configurados na Vercel e, para `CRON_SECRET`, também no GitHub. +- **Limitações aceitas**: login do Google expira a cada 7 dias em modo Teste; sincronização de e-mail sujeita ao atraso do agendador do GitHub Actions; termos do plano Hobby preveem uso não comercial — reavaliar o plano Pro quando o uso se consolidar. +- **Atualizações futuras**: merges de `upstream` podem conflitar com `apps/api/vercel.json`. diff --git a/openspec/changes/deploy-vercel-producao/specs/operacao-producao/spec.md b/openspec/changes/deploy-vercel-producao/specs/operacao-producao/spec.md new file mode 100644 index 000000000..34a1cf878 --- /dev/null +++ b/openspec/changes/deploy-vercel-producao/specs/operacao-producao/spec.md @@ -0,0 +1,63 @@ +## Purpose + +Define como o CRM se comporta quando publicado na internet para a equipe da guardon.me: quem consegue entrar, como a sessão funciona entre os subdomínios, como as tarefas periódicas rodam dentro do plano gratuito e o que acontece quando o agente não está publicado. + +## ADDED Requirements + +### Requirement: Acesso restrito à equipe +O sistema SHALL permitir login apenas a contas cujo e-mail conste explicitamente na lista de acesso de produção, e SHALL recusar qualquer outra conta, inclusive de domínios públicos como `gmail.com`. + +#### Scenario: Membro da equipe entra +- **WHEN** uma pessoa cujo e-mail está na lista de acesso faz login com o Google em `crm.guardon.me` +- **THEN** o sistema cria a sessão e exibe o CRM + +#### Scenario: Pessoa de fora é recusada +- **WHEN** uma conta Google válida que não está na lista de acesso tenta fazer login +- **THEN** o sistema recusa o login e não cria usuário nem sessão + +#### Scenario: Lista de acesso vazia +- **WHEN** a lista de acesso de produção está vazia ou ausente +- **THEN** ninguém consegue fazer login + +### Requirement: Sessão única entre app e API +O sistema SHALL manter uma única sessão válida para o app (`crm.guardon.me`) e para a API (`api.guardon.me`) após o login, sem loops de redirecionamento. + +#### Scenario: Navegação após login +- **WHEN** um usuário conclui o login e o app faz chamadas à API +- **THEN** as chamadas são autenticadas com a mesma sessão, sem nova solicitação de login + +### Requirement: Tráfego exclusivamente seguro +O sistema SHALL ser servido somente por HTTPS nos domínios de produção. + +#### Scenario: Acesso por HTTP +- **WHEN** alguém acessa `http://crm.guardon.me` +- **THEN** é redirecionado para `https://crm.guardon.me` + +### Requirement: Sincronização periódica de e-mail +O sistema SHALL executar a sincronização das caixas de e-mail conectadas com intervalo típico de até 15 minutos, disparada por um agendador autenticado com o segredo de cron, e SHALL recusar disparos sem o segredo correto. + +#### Scenario: Disparo autenticado +- **WHEN** o agendador chama a rota de sincronização com o segredo de cron correto +- **THEN** o sistema sincroniza as caixas cujo prazo venceu e responde com sucesso + +#### Scenario: Disparo sem segredo +- **WHEN** a rota de sincronização é chamada sem o segredo ou com um segredo incorreto +- **THEN** o sistema recusa a execução e não sincroniza nenhuma caixa + +#### Scenario: Novo e-mail aparece no CRM +- **WHEN** um membro com o Gmail conectado recebe um e-mail de um contato +- **THEN** o e-mail aparece na linha do tempo desse contato em até ~15 minutos + +### Requirement: Tarefas de manutenção diárias +O sistema SHALL executar, ao menos uma vez por dia, a atualização de câmbio, a retenção de dados de rastreamento e a limpeza de arquivados. + +#### Scenario: Execução diária +- **WHEN** passa um dia desde a última execução +- **THEN** cada tarefa de manutenção executou uma vez + +### Requirement: Funcionamento sem o agente +O sistema SHALL permanecer utilizável — login, contatos, empresas, negócios, e-mail — quando o agente de pesquisa não estiver publicado, informando na aba do agente que ele não está configurado em vez de exibir erro. + +#### Scenario: Aba do agente sem agente publicado +- **WHEN** um usuário abre a aba Agente de um contato e o agente não está publicado +- **THEN** o sistema informa que o agente não está configurado e as demais abas continuam funcionando diff --git a/openspec/changes/deploy-vercel-producao/tasks.md b/openspec/changes/deploy-vercel-producao/tasks.md new file mode 100644 index 000000000..cf6aa9ca0 --- /dev/null +++ b/openspec/changes/deploy-vercel-producao/tasks.md @@ -0,0 +1,58 @@ +## 1. Repositório próprio + +- [x] 1.1 Criar o fork de `trycompai/crm` na conta GitHub do usuário e verificar que ele aparece em `github.com//crm` +- [x] 1.2 Renomear o remote local `origin` para `upstream`, adicionar o fork como `origin` e verificar com `git remote -v` +- [ ] 1.3 Commitar `openspec/` e as adaptações na branch `release` e verificar o push com `git log origin/release -1` +- [ ] 1.5 No fork, habilitar Actions e desativar os workflows `auto-pr`, `release`, `pr-base` e `pr-title`; verificar que apenas `CI` e `Sync mailboxes` aparecem ativos +- [x] 1.4 Confirmar que `.env` não está versionado (`git check-ignore .env` retorna `.env`) + +## 2. Adaptação ao plano Hobby + +- [x] 2.1 Alterar `apps/api/vercel.json` para que todas as crons rodem no máximo uma vez por dia (mailboxes incluída como fallback diário) e verificar que nenhuma expressão tem frequência maior que diária +- [ ] 2.2 Criar `.github/workflows/sync-mailboxes.yml` agendado a cada 10 min que chama `GET $API_URL/internal/sync/mailboxes` com `Authorization: Bearer ${{ secrets.CRON_SECRET }}` e falha se a resposta não for 2xx; verificar a sintaxe com `workflow_dispatch` manual após o deploy +- [ ] 2.3 Commitar e dar push das adaptações e verificar que o fork contém os dois arquivos + +## 3. Banco de dados + +- [ ] 3.1 Criar o banco Neon Free pela Vercel Marketplace (região próxima: `sa-east-1` se disponível, senão `us-east-1`) e verificar que `DATABASE_URL` e `POSTGRES_URL_NON_POOLING` foram gerados + +## 4. Projeto Vercel da API + +- [ ] 4.1 Importar o fork na Vercel como projeto `crm-api`, Root Directory `apps/api`, e conectar o banco Neon ao projeto +- [ ] 4.2 Gerar segredos novos de produção (`BETTER_AUTH_SECRET`, `CRON_SECRET` ≥16 chars, `AGENT_BRIDGE_SECRET`) e guardá-los num gerenciador de senhas; verificar que nenhum é igual ao do `.env` local +- [ ] 4.3 Configurar as variáveis da API (`BETTER_AUTH_SECRET`, `ALLOWED_SIGN_IN`, `GOOGLE_CLIENT_ID/SECRET`, `CRON_SECRET`, `API_URL=https://api.guardon.me`, `APP_URL=https://crm.guardon.me`, `AUTH_COOKIE_DOMAIN=.guardon.me`) e verificar a lista na aba Environment Variables +- [ ] 4.4 Fazer o deploy e verificar no log de build que `prisma migrate deploy` aplicou todas as migrations e que a rota de health responde 200 + +## 5. Projeto Vercel do app + +- [ ] 5.1 Importar o fork como projeto `crm-app`, Root Directory `apps/app`, com o mesmo banco e as mesmas variáveis de auth/URLs da API; verificar que `BETTER_AUTH_SECRET` é idêntico nos dois projetos +- [ ] 5.2 Fazer o deploy e verificar que a URL `*.vercel.app` do app carrega a página de login + +## 6. Domínios + +- [ ] 6.1 Adicionar `crm.guardon.me` ao projeto app e `api.guardon.me` ao projeto API; criar os registros CNAME no DNS de `guardon.me` e verificar que a Vercel mostra "Valid Configuration" e certificado emitido +- [ ] 6.2 Redeploy dos dois projetos e verificar que `http://crm.guardon.me` redireciona para HTTPS + +## 7. Google OAuth de produção + +- [ ] 7.1 No cliente OAuth do projeto `MyCRM`, adicionar a origem `https://crm.guardon.me` e o redirecionamento `https://api.guardon.me/api/auth/callback/google`; verificar que as entradas de localhost continuam presentes +- [ ] 7.2 Adicionar os e-mails de toda a equipe como usuários de teste em Público-alvo e verificar a lista +- [ ] 7.3 Confirmar que Gmail API e Calendar API estão ativadas no projeto + +## 8. Agendador externo + +- [ ] 8.1 Criar o secret `CRON_SECRET` (e a variável `API_URL`) no fork em Settings → Secrets and variables → Actions +- [ ] 8.2 Rodar o workflow manualmente (`workflow_dispatch`) e verificar resposta 2xx; em seguida chamar a rota sem o header e verificar que é recusada + +## 9. Validação ponta a ponta + +- [ ] 9.1 Login do usuário principal em `https://crm.guardon.me` e verificar que ele vira owner do workspace e conclui o onboarding +- [ ] 9.2 Login de cada membro da equipe e verificar acesso; tentar com uma conta fora da lista e verificar recusa +- [ ] 9.3 Conectar o Gmail, enviar um e-mail de teste de um contato e verificar que aparece na linha do tempo em até ~15 min +- [ ] 9.4 Abrir a aba Agente de um contato e verificar a mensagem de "não configurado" sem quebrar a página + +## 10. Spike do agente (decisão, não deploy) + +- [ ] 10.1 Investigar como o eve publica `defineSchedule({ cron: "* * * * *" })` na Vercel e se o plano Hobby aceita; registrar o achado em `design.md` +- [ ] 10.2 Verificar se o disparo externo de `/internal/crm/dispatch` com `AGENT_BRIDGE_SECRET` substitui o agendamento por minuto, e estimar o custo de sandbox + AI Gateway no Hobby; registrar em `design.md` +- [ ] 10.3 Decidir com o usuário: publicar o agente (nova change) ou manter o CRM sem agente por ora diff --git a/openspec/changes/gerador-de-propostas/.openspec.yaml b/openspec/changes/gerador-de-propostas/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/gerador-de-propostas/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/gerador-de-propostas/proposal.md b/openspec/changes/gerador-de-propostas/proposal.md new file mode 100644 index 000000000..8de55efd3 --- /dev/null +++ b/openspec/changes/gerador-de-propostas/proposal.md @@ -0,0 +1,31 @@ +## Why + +Hoje cada proposta comercial da guardon.me é montada à mão no Word, copiando dados do cliente e preços de um modelo — trabalho repetitivo e sujeito a erro. Como o CRM já concentra empresa, contatos e valor de cada negócio, gerar a proposta a partir dele elimina a redigitação sem criar mais um sistema no dia a dia. O projeto original declara explicitamente que não terá orçamentos/propostas, então esta funcionalidade é própria do fork e não disputa espaço com atualizações futuras. + +## What Changes + +- **Catálogo de serviços**: tabela de serviços/itens com descrição e preço padrão, mantida pela equipe no próprio CRM. +- **Itens da proposta**: em cada negócio, a pessoa escolhe itens do catálogo e pode ajustar quantidade, preço e descrição daquela proposta sem alterar o catálogo. +- **Modelo Word**: upload de um modelo `.docx` com marcadores (ex.: empresa, contato, data, itens, total) que o sistema preenche. +- **Geração**: botão no negócio que gera o `.docx` preenchido para download; revisão e envio ao cliente continuam manuais, fora do CRM. +- **Histórico**: cada proposta gerada fica guardada no negócio como uma versão (v1, v2, …), com data, autor, itens e valor total, e pode ser baixada novamente. +- **Fora de escopo agora**: envio automático por e-mail, aceite/assinatura digital, redação de texto por IA (candidata a uma change futura usando o agente). + +## Capabilities + +### New Capabilities +- `propostas/catalogo-servicos`: cadastro e manutenção dos serviços e preços padrão usados nas propostas. +- `propostas/geracao-proposta`: montagem dos itens de uma proposta a partir de um negócio, preenchimento do modelo Word e download do documento. +- `propostas/historico-propostas`: versões de propostas guardadas por negócio e seu download posterior. + +### Modified Capabilities + + +## Impact + +- **Banco**: novas tabelas (catálogo, propostas, itens de proposta, modelo) via nova migration; nenhuma alteração nas tabelas existentes além de relações com `Deal`. +- **API**: novo módulo isolado (roteador tRPC próprio) para catálogo, propostas e geração do `.docx`; preenchimento de modelo é determinístico e não viola a regra do projeto de manter "inteligência" fora da API. +- **App**: nova aba "Propostas" na página do negócio e uma tela de catálogo nas configurações. +- **Armazenamento de arquivos**: modelo e documentos gerados precisam de um lugar para ficar (Vercel Blob ou o próprio Postgres) — decisão no design. +- **Dependência nova**: biblioteca de preenchimento de `.docx` na API. +- **Pendente do usuário**: o modelo Word definitivo (em aprimoramento) — necessário para fechar a lista de marcadores, specs e design antes da implementação. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 000000000..302559b4a --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,28 @@ +schema: spec-driven + +context: | + Language: pt-BR + All artifacts must be written in pt-BR. + Keep OpenSpec structural headings and SHALL/MUST keywords in English. + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours + +# Per-operation guidance (optional) +# Add advisory guidance for how apply and archive work should be conducted. +# This is separate from artifact rules above. +# Example: +# operations: +# apply: +# guidance: +# - Keep test summaries concise +# archive: +# guidance: +# - Summarize the archive outcome before finishing From 762a29b7174c5eb2128682c3200d0010e3e20eab Mon Sep 17 00:00:00 2001 From: Marcelo Nascimento Date: Sun, 27 Sep 2026 12:05:17 +0100 Subject: [PATCH 2/5] docs(openspec): run production in Frankfurt with EUR --- openspec/changes/deploy-vercel-producao/design.md | 4 ++++ openspec/changes/deploy-vercel-producao/tasks.md | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/openspec/changes/deploy-vercel-producao/design.md b/openspec/changes/deploy-vercel-producao/design.md index 449243037..f54492316 100644 --- a/openspec/changes/deploy-vercel-producao/design.md +++ b/openspec/changes/deploy-vercel-producao/design.md @@ -34,6 +34,10 @@ Deploys da Vercel saem da branch `release` do fork (Production Branch na Vercel Integração injeta `DATABASE_URL` (pooled) e `POSTGRES_URL_NON_POOLING`; o build usa a conexão direta para `prisma migrate deploy`. 0,5 GB atende 3–4 usuários. - *Alternativa*: Supabase Free. Equivalente; Neon escolhido pela integração nativa e pelo suporte explícito a `POSTGRES_URL_NON_POOLING` no `.env.example`. +### D3.1 — Banco e funções na mesma região europeia (Frankfurt) +A equipe está em Portugal. Neon em `aws-eu-central-1` (Frankfurt) e Function Region `fra1` nos dois projetos Vercel, configurada no painel (Settings → Functions) em vez de `regions` no `vercel.json`, para não divergir do `upstream`. O padrão do Hobby é `iad1`; manter o padrão colocaria ~90 ms de latência transatlântica em cada consulta ao banco. +- *Alternativa*: Londres (`eu-west-2` / `lhr1`). Equivalente em latência para Lisboa; Frankfurt escolhido por ser a região europeia mais completa em ambos os provedores. + ### D4 — Crons diárias na Vercel + GitHub Actions para caixas de e-mail `apps/api/vercel.json` passa a ter todas as crons diárias (compatível com Hobby). Um workflow agendado no fork chama `GET https://api.guardon.me/internal/sync/mailboxes` com `Authorization: Bearer $CRON_SECRET` a cada 10 minutos. As rotas já aceitam GET e POST e já validam `CRON_SECRET` em tempo constante. - *Alternativa*: cron-job.org. Viável, mas coloca o segredo em mais um serviço externo; GitHub Actions mantém tudo no fork. diff --git a/openspec/changes/deploy-vercel-producao/tasks.md b/openspec/changes/deploy-vercel-producao/tasks.md index cf6aa9ca0..2d6ae861b 100644 --- a/openspec/changes/deploy-vercel-producao/tasks.md +++ b/openspec/changes/deploy-vercel-producao/tasks.md @@ -14,11 +14,12 @@ ## 3. Banco de dados -- [ ] 3.1 Criar o banco Neon Free pela Vercel Marketplace (região próxima: `sa-east-1` se disponível, senão `us-east-1`) e verificar que `DATABASE_URL` e `POSTGRES_URL_NON_POOLING` foram gerados +- [ ] 3.1 Criar o banco Neon Free pela Vercel Marketplace (região `aws-eu-central-1` (Frankfurt)) e verificar que `DATABASE_URL` e `POSTGRES_URL_NON_POOLING` foram gerados ## 4. Projeto Vercel da API - [ ] 4.1 Importar o fork na Vercel como projeto `crm-api`, Root Directory `apps/api`, e conectar o banco Neon ao projeto +- [ ] 4.1.1 Definir Function Region `fra1` (Frankfurt) em Settings → Functions do projeto `crm-api` e verificar no log do deploy que as funções rodam em `fra1` - [ ] 4.2 Gerar segredos novos de produção (`BETTER_AUTH_SECRET`, `CRON_SECRET` ≥16 chars, `AGENT_BRIDGE_SECRET`) e guardá-los num gerenciador de senhas; verificar que nenhum é igual ao do `.env` local - [ ] 4.3 Configurar as variáveis da API (`BETTER_AUTH_SECRET`, `ALLOWED_SIGN_IN`, `GOOGLE_CLIENT_ID/SECRET`, `CRON_SECRET`, `API_URL=https://api.guardon.me`, `APP_URL=https://crm.guardon.me`, `AUTH_COOKIE_DOMAIN=.guardon.me`) e verificar a lista na aba Environment Variables - [ ] 4.4 Fazer o deploy e verificar no log de build que `prisma migrate deploy` aplicou todas as migrations e que a rota de health responde 200 @@ -26,6 +27,7 @@ ## 5. Projeto Vercel do app - [ ] 5.1 Importar o fork como projeto `crm-app`, Root Directory `apps/app`, com o mesmo banco e as mesmas variáveis de auth/URLs da API; verificar que `BETTER_AUTH_SECRET` é idêntico nos dois projetos +- [ ] 5.1.1 Definir Function Region `fra1` (Frankfurt) em Settings → Functions do projeto `crm-app` e verificar no log do deploy - [ ] 5.2 Fazer o deploy e verificar que a URL `*.vercel.app` do app carrega a página de login ## 6. Domínios @@ -47,6 +49,7 @@ ## 9. Validação ponta a ponta - [ ] 9.1 Login do usuário principal em `https://crm.guardon.me` e verificar que ele vira owner do workspace e conclui o onboarding +- [ ] 9.1.1 Definir EUR como moeda base do workspace em Configurações → Moedas e verificar que um negócio novo nasce em EUR - [ ] 9.2 Login de cada membro da equipe e verificar acesso; tentar com uma conta fora da lista e verificar recusa - [ ] 9.3 Conectar o Gmail, enviar um e-mail de teste de um contato e verificar que aparece na linha do tempo em até ~15 min - [ ] 9.4 Abrir a aba Agente de um contato e verificar a mensagem de "não configurado" sem quebrar a página From 11f45d909eea622e0e2604777dcc7b0d482e2b87 Mon Sep 17 00:00:00 2001 From: Marcelo Nascimento Date: Sun, 27 Sep 2026 22:07:20 +0100 Subject: [PATCH 3/5] fix(api): deploy functions to fra1 and read crons from vercel.json The Build Output API config wrote its own every-5-minutes cron and pinned the function to iad1, so the dashboard region and the daily crons in apps/api/vercel.json never applied. The build now reads the crons from vercel.json and deploys the function next to the Frankfurt database. --- apps/api/scripts/build-func.mjs | 4 ++-- openspec/changes/deploy-vercel-producao/design.md | 10 ++++++---- openspec/changes/deploy-vercel-producao/tasks.md | 10 +++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/api/scripts/build-func.mjs b/apps/api/scripts/build-func.mjs index b06f0c017..975d1d0a9 100644 --- a/apps/api/scripts/build-func.mjs +++ b/apps/api/scripts/build-func.mjs @@ -169,7 +169,7 @@ writeFileSync( maxDuration: 60, memory: 1769, environment: { NODE_ENV: "production" }, - regions: ["iad1"], + regions: ["fra1"], }), ); writeFileSync( @@ -177,7 +177,7 @@ writeFileSync( JSON.stringify({ version: 3, routes: [{ src: "/(.*)", dest: "/api/index" }], - crons: [{ path: "/internal/sync/google", schedule: "*/5 * * * *" }], + crons: JSON.parse(readFileSync(join(apiDir, "vercel.json"), "utf8")).crons, }), ); diff --git a/openspec/changes/deploy-vercel-producao/design.md b/openspec/changes/deploy-vercel-producao/design.md index f54492316..8e5d85e42 100644 --- a/openspec/changes/deploy-vercel-producao/design.md +++ b/openspec/changes/deploy-vercel-producao/design.md @@ -26,8 +26,8 @@ Deploys da Vercel saem da branch `release` do fork (Production Branch na Vercel - *Alternativa*: cópia sem vínculo (novo repositório). Descartada: perde o caminho de atualização. - Workflows herdados que não se aplicam ao fork (`auto-pr`, `release`, `pr-base`, `pr-title`) são desativados pela interface do GitHub (Actions → workflow → Disable), sem editar os arquivos, para não criar conflitos com o `upstream`. -### D2 — Dois projetos Vercel, um por app, com Root Directory em `apps/app` e `apps/api` -É o modelo que o projeto original já espera (um `vercel.json` por app). Domínios: `crm.guardon.me` → app, `api.guardon.me` → API. `AUTH_COOKIE_DOMAIN=.guardon.me` para uma sessão cobrir ambos. +### D2 — Dois projetos Vercel: `crm-app` (Root Directory `apps/app`) e `crm-api` (Root Directory na raiz) +O `crm-api` usa Framework Preset "Other", Build Command `node apps/api/scripts/build-func.mjs` e Install Command `bun install`, porque o script grava a Build Output API em `/.vercel/output`. O `crm-app` é um projeto Next.js comum em `apps/app`. Domínios: `crm.guardon.me` → app, `api.guardon.me` → API. `AUTH_COOKIE_DOMAIN=.guardon.me` para uma sessão cobrir ambos. - *Alternativa*: API servida sob o mesmo domínio do app via rewrites. Descartada: diverge do upstream e complica `/api/auth/*`. ### D3 — Neon Free via Vercel Marketplace @@ -35,11 +35,13 @@ Integração injeta `DATABASE_URL` (pooled) e `POSTGRES_URL_NON_POOLING`; o buil - *Alternativa*: Supabase Free. Equivalente; Neon escolhido pela integração nativa e pelo suporte explícito a `POSTGRES_URL_NON_POOLING` no `.env.example`. ### D3.1 — Banco e funções na mesma região europeia (Frankfurt) -A equipe está em Portugal. Neon em `aws-eu-central-1` (Frankfurt) e Function Region `fra1` nos dois projetos Vercel, configurada no painel (Settings → Functions) em vez de `regions` no `vercel.json`, para não divergir do `upstream`. O padrão do Hobby é `iad1`; manter o padrão colocaria ~90 ms de latência transatlântica em cada consulta ao banco. +A equipe está em Portugal. Neon em `aws-eu-central-1` (Frankfurt) e funções em `fra1`. O padrão do Hobby é `iad1`; manter o padrão colocaria ~90 ms de latência transatlântica em cada consulta ao banco. +- **API**: o `crm-api` é publicado pela Build Output API via `apps/api/scripts/build-func.mjs`, que grava `regions` em `.vc-config.json` e ignora a Function Region do painel. A região é alterada nesse script (`iad1` → `fra1`). +- **App**: Next.js respeita a Function Region do painel (Settings → Functions → `fra1`). - *Alternativa*: Londres (`eu-west-2` / `lhr1`). Equivalente em latência para Lisboa; Frankfurt escolhido por ser a região europeia mais completa em ambos os provedores. ### D4 — Crons diárias na Vercel + GitHub Actions para caixas de e-mail -`apps/api/vercel.json` passa a ter todas as crons diárias (compatível com Hobby). Um workflow agendado no fork chama `GET https://api.guardon.me/internal/sync/mailboxes` com `Authorization: Bearer $CRON_SECRET` a cada 10 minutos. As rotas já aceitam GET e POST e já validam `CRON_SECRET` em tempo constante. +`apps/api/vercel.json` passa a ter todas as crons diárias (compatível com Hobby) e vira a fonte única: o `build-func.mjs` gravava no `config.json` da Build Output API uma cron própria (`/internal/sync/google`, `*/5`), independente do `vercel.json`; agora ele lê as crons do `vercel.json`. Um workflow agendado no fork chama `GET https://api.guardon.me/internal/sync/mailboxes` com `Authorization: Bearer $CRON_SECRET` a cada 10 minutos. As rotas já aceitam GET e POST e já validam `CRON_SECRET` em tempo constante. - *Alternativa*: cron-job.org. Viável, mas coloca o segredo em mais um serviço externo; GitHub Actions mantém tudo no fork. - *Trade-off*: o agendamento do GitHub Actions atrasa em horários de pico e é pausado após 60 dias sem atividade no repositório. diff --git a/openspec/changes/deploy-vercel-producao/tasks.md b/openspec/changes/deploy-vercel-producao/tasks.md index 2d6ae861b..5277500e9 100644 --- a/openspec/changes/deploy-vercel-producao/tasks.md +++ b/openspec/changes/deploy-vercel-producao/tasks.md @@ -2,8 +2,8 @@ - [x] 1.1 Criar o fork de `trycompai/crm` na conta GitHub do usuário e verificar que ele aparece em `github.com//crm` - [x] 1.2 Renomear o remote local `origin` para `upstream`, adicionar o fork como `origin` e verificar com `git remote -v` -- [ ] 1.3 Commitar `openspec/` e as adaptações na branch `release` e verificar o push com `git log origin/release -1` -- [ ] 1.5 No fork, habilitar Actions e desativar os workflows `auto-pr`, `release`, `pr-base` e `pr-title`; verificar que apenas `CI` e `Sync mailboxes` aparecem ativos +- [x] 1.3 Commitar `openspec/` e as adaptações na branch `release` e verificar o push com `git log origin/release -1` +- [x] 1.5 No fork, habilitar Actions e desativar os workflows `auto-pr`, `release`, `pr-base` e `pr-title`; verificar que apenas `CI` e `Sync mailboxes` aparecem ativos - [x] 1.4 Confirmar que `.env` não está versionado (`git check-ignore .env` retorna `.env`) ## 2. Adaptação ao plano Hobby @@ -14,12 +14,12 @@ ## 3. Banco de dados -- [ ] 3.1 Criar o banco Neon Free pela Vercel Marketplace (região `aws-eu-central-1` (Frankfurt)) e verificar que `DATABASE_URL` e `POSTGRES_URL_NON_POOLING` foram gerados +- [x] 3.1 Criar o banco Neon Free pela Vercel Marketplace (região `aws-eu-central-1` (Frankfurt)) e verificar que `DATABASE_URL` e `POSTGRES_URL_NON_POOLING` foram gerados ## 4. Projeto Vercel da API -- [ ] 4.1 Importar o fork na Vercel como projeto `crm-api`, Root Directory `apps/api`, e conectar o banco Neon ao projeto -- [ ] 4.1.1 Definir Function Region `fra1` (Frankfurt) em Settings → Functions do projeto `crm-api` e verificar no log do deploy que as funções rodam em `fra1` +- [ ] 4.1 Importar o fork na Vercel como projeto `crm-api`: Root Directory na raiz do repositório, Framework Preset "Other", Build Command `node apps/api/scripts/build-func.mjs`, Install Command `bun install`, Production Branch `release`; conectar o banco Neon ao projeto +- [x] 4.1.1 Fixar a região das funções da API em `fra1` e as crons diárias em `apps/api/scripts/build-func.mjs` (lendo de `apps/api/vercel.json`); verificado com build local: `.vc-config.json` com `fra1` e `config.json` com 5 crons diárias - [ ] 4.2 Gerar segredos novos de produção (`BETTER_AUTH_SECRET`, `CRON_SECRET` ≥16 chars, `AGENT_BRIDGE_SECRET`) e guardá-los num gerenciador de senhas; verificar que nenhum é igual ao do `.env` local - [ ] 4.3 Configurar as variáveis da API (`BETTER_AUTH_SECRET`, `ALLOWED_SIGN_IN`, `GOOGLE_CLIENT_ID/SECRET`, `CRON_SECRET`, `API_URL=https://api.guardon.me`, `APP_URL=https://crm.guardon.me`, `AUTH_COOKIE_DOMAIN=.guardon.me`) e verificar a lista na aba Environment Variables - [ ] 4.4 Fazer o deploy e verificar no log de build que `prisma migrate deploy` aplicou todas as migrations e que a rota de health responde 200 From 6015aa578aa8350c08f3a65ddc3e5deb1b23de66 Mon Sep 17 00:00:00 2001 From: Marcelo Nascimento Date: Thu, 1 Oct 2026 10:53:48 +0100 Subject: [PATCH 4/5] feat(proposals): generate Word proposals per issuer from the deal Issuers with their own .docx templates, a service catalog with a price per issuer, one issuer per client, versioned proposals with the generated and the final file, status that offers the deal stage, and the client logo filled into the template. Owner only. --- AGENTS.md | 1 + apps/api/package.json | 2 + apps/api/src/app.module.ts | 2 + apps/api/src/generated/server.ts | 93 +++ apps/api/src/proposals/catalog.service.ts | 146 +++++ apps/api/src/proposals/issuers.service.ts | 383 ++++++++++++ apps/api/src/proposals/prisma-code.ts | 7 + .../src/proposals/proposal-access.service.ts | 21 + apps/api/src/proposals/proposal-data.ts | 138 +++++ apps/api/src/proposals/proposal-docx.ts | 232 +++++++ apps/api/src/proposals/proposal-format.ts | 53 ++ apps/api/src/proposals/proposals.config.ts | 25 + apps/api/src/proposals/proposals.contracts.ts | 288 +++++++++ apps/api/src/proposals/proposals.module.ts | 19 + apps/api/src/proposals/proposals.router.ts | 355 +++++++++++ apps/api/src/proposals/proposals.service.ts | 564 ++++++++++++++++++ apps/api/test/fixtures/broken-loop.docx | Bin 0 -> 36591 bytes apps/api/test/fixtures/proposal-template.docx | Bin 0 -> 39725 bytes apps/api/test/fixtures/unknown-marker.docx | Bin 0 -> 36588 bytes apps/api/test/proposal-docx.spec.ts | 148 +++++ apps/api/test/proposal-format.spec.ts | 57 ++ .../api/test/proposals-db.integration.spec.ts | 114 ++++ apps/api/test/proposals.integration.spec.ts | 471 +++++++++++++++ .../settings/proposals/issuers-panel.tsx | 444 ++++++++++++++ .../(app)/[slug]/settings/proposals/page.tsx | 58 ++ .../settings/proposals/proposal-settings.tsx | 54 ++ .../settings/proposals/service-sheet.tsx | 279 +++++++++ .../settings/proposals/services-panel.tsx | 159 +++++ .../[slug]/settings/settings-sidebar.tsx | 17 +- .../proposals/company-proposal-profile.tsx | 260 ++++++++ .../crm/proposals/content-editor.tsx | 389 ++++++++++++ .../crm/proposals/deal-proposals.tsx | 478 +++++++++++++++ .../crm/proposals/money-editors.tsx | 282 +++++++++ .../crm/proposals/proposal-config.ts | 24 + .../crm/proposals/proposal-editor.tsx | 410 +++++++++++++ .../crm/proposals/proposal-files.ts | 118 ++++ .../crm/proposals/proposal-types.ts | 79 +++ .../crm/record-sheet/company-sheet.tsx | 3 + .../crm/record-sheet/deal-sheet.tsx | 11 + apps/app/lib/trpc/cache.ts | 3 + bun.lock | 16 +- docs/proposals.md | 58 ++ .../changes/gerador-de-propostas/design.md | 170 ++++++ .../changes/gerador-de-propostas/proposal.md | 38 +- .../specs/propostas/catalogo-servicos/spec.md | 41 ++ .../specs/propostas/emissores/spec.md | 64 ++ .../specs/propostas/geracao-proposta/spec.md | 93 +++ .../propostas/historico-propostas/spec.md | 51 ++ .../changes/gerador-de-propostas/tasks.md | 62 ++ packages/auth/src/index.ts | 1 + packages/auth/src/proposals.ts | 5 + packages/auth/test/proposals.spec.ts | 11 + .../20260929202004_proposals/migration.sql | 156 +++++ packages/db/prisma/schema.prisma | 143 +++++ packages/validation/package.json | 1 + packages/validation/src/proposal.ts | 123 ++++ .../test/fixtures/web-app-pentest.ts | 149 +++++ packages/validation/test/proposal.spec.ts | 79 +++ 58 files changed, 7429 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/proposals/catalog.service.ts create mode 100644 apps/api/src/proposals/issuers.service.ts create mode 100644 apps/api/src/proposals/prisma-code.ts create mode 100644 apps/api/src/proposals/proposal-access.service.ts create mode 100644 apps/api/src/proposals/proposal-data.ts create mode 100644 apps/api/src/proposals/proposal-docx.ts create mode 100644 apps/api/src/proposals/proposal-format.ts create mode 100644 apps/api/src/proposals/proposals.config.ts create mode 100644 apps/api/src/proposals/proposals.contracts.ts create mode 100644 apps/api/src/proposals/proposals.module.ts create mode 100644 apps/api/src/proposals/proposals.router.ts create mode 100644 apps/api/src/proposals/proposals.service.ts create mode 100644 apps/api/test/fixtures/broken-loop.docx create mode 100644 apps/api/test/fixtures/proposal-template.docx create mode 100644 apps/api/test/fixtures/unknown-marker.docx create mode 100644 apps/api/test/proposal-docx.spec.ts create mode 100644 apps/api/test/proposal-format.spec.ts create mode 100644 apps/api/test/proposals-db.integration.spec.ts create mode 100644 apps/api/test/proposals.integration.spec.ts create mode 100644 apps/app/app/(app)/[slug]/settings/proposals/issuers-panel.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/proposals/page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/proposals/proposal-settings.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/proposals/service-sheet.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/proposals/services-panel.tsx create mode 100644 apps/app/components/crm/proposals/company-proposal-profile.tsx create mode 100644 apps/app/components/crm/proposals/content-editor.tsx create mode 100644 apps/app/components/crm/proposals/deal-proposals.tsx create mode 100644 apps/app/components/crm/proposals/money-editors.tsx create mode 100644 apps/app/components/crm/proposals/proposal-config.ts create mode 100644 apps/app/components/crm/proposals/proposal-editor.tsx create mode 100644 apps/app/components/crm/proposals/proposal-files.ts create mode 100644 apps/app/components/crm/proposals/proposal-types.ts create mode 100644 docs/proposals.md create mode 100644 openspec/changes/gerador-de-propostas/design.md create mode 100644 openspec/changes/gerador-de-propostas/specs/propostas/catalogo-servicos/spec.md create mode 100644 openspec/changes/gerador-de-propostas/specs/propostas/emissores/spec.md create mode 100644 openspec/changes/gerador-de-propostas/specs/propostas/geracao-proposta/spec.md create mode 100644 openspec/changes/gerador-de-propostas/specs/propostas/historico-propostas/spec.md create mode 100644 openspec/changes/gerador-de-propostas/tasks.md create mode 100644 packages/auth/src/proposals.ts create mode 100644 packages/auth/test/proposals.spec.ts create mode 100644 packages/db/prisma/migrations/20260929202004_proposals/migration.sql create mode 100644 packages/validation/src/proposal.ts create mode 100644 packages/validation/test/fixtures/web-app-pentest.ts create mode 100644 packages/validation/test/proposal.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 2be0b3881..33f4801ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ context until you read them, and the rules in them are not optional. | The record sheet's Agent tab | `docs/agent-panel.md` | | `/settings/connections`, integrations, the intake endpoint | `docs/connections.md` | | The tracking script, the collector, form submissions | `docs/tracking.md` | +| Proposals: issuers, templates, catalog, `apps/api/src/proposals` | `docs/proposals.md` | | Running it locally, Google Cloud, DB commands, secrets | `docs/setup.md` | | Anything that sends a telemetry event, or a new property on one | `docs/telemetry.md` | | `.github/workflows`, versions, changelog, how a change reaches `release` | `CONTRIBUTING.md` | diff --git a/apps/api/package.json b/apps/api/package.json index 151aec0f8..1837e9c90 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,8 +43,10 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "context.dev": "^2.7.0", + "docxtemplater": "3.71.0", "helmet": "^8.3.0", "nestjs-trpc": "^2.13.0", + "pizzip": "3.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "trpc-to-openapi": "^3.3.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 82a345df2..8e21e9654 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -26,6 +26,7 @@ import { LoggingModule } from "./logging/logging.module"; import { logAuthRoute } from "./logging/request-logger.middleware"; import { MailboxModule } from "./mailbox/mailbox.module"; import { MicrosoftModule } from "./microsoft/microsoft.module"; +import { ProposalsModule } from "./proposals/proposals.module"; import { SavedViewsModule } from "./saved-views/saved-views.module"; import { SearchModule } from "./search/search.module"; import { SettingsModule } from "./settings/settings.module"; @@ -79,6 +80,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; TrackingModule, ArchiveModule, SavedViewsModule, + ProposalsModule, ], }) export class AppModule {} diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b77da4001..969534f68 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -26,6 +26,7 @@ import { enrichmentQueueInput } from "@crm/validation/enrichment-queue"; import { fieldListInput, fieldListOutput, fieldByKeyInput, serializedFieldOutput, fieldEntityInput, fieldFiltersOutput, fieldIdInput, fieldCoverageOutput, fieldCreateInput, fieldUpdateArgs, fieldReorderInput, fieldReorderOutput, fieldDeleteOutput, fieldBackfillOutput } from "../fields/fields.contracts"; import { googleConnectionStatusOutput, setAutoCreateInput, suppressDomainInput, suppressDomainOutput, threadInput, emailThreadOutput, calendarEventInput, calendarEventOutput } from "../google/google.contracts"; import { purgeSyncedDataOutput, revokeAccessOutput, microsoftConnectionStatusOutput, setOutlookAutoCreateInput } from "../microsoft/microsoft.contracts"; +import { accessOutput, issuersOutput, createIssuerInput, idInput, updateIssuerInput, okOutput, uploadTemplateInput, templateOutput, fileOutput, companyInput, companyProfileOutput, setCompanyIssuerInput, setClientLogoInput, listServicesInput, servicesOutput, serviceOutput, saveServiceInput, dealInput, dealProposalsOutput, draftInput, draftOutput, generateInput, generateOutput, downloadInput, uploadFinalInput, setStatusInput, setStatusOutput } from "../proposals/proposals.contracts"; import { savedViewListInput, savedViewListOutput, savedViewCreateInput, savedViewOutput, savedViewUpdateArgs, savedViewIdInput, savedViewDeleteOutput } from "../saved-views/saved-views.contracts"; import { agentModelOutput, modelCatalogOutput, setAgentModelInput, researchKeyOutput, setResearchKeyInput, archiveRetentionOutput, setArchiveRetentionDaysInput } from "../settings/settings.contracts"; import { slackStatusOutput, slackMatchesOutput, slackChannelsInput, slackChannelsOutput, slackJoinChannelInput, slackJoinChannelOutput, slackRefreshPeopleOutput, slackCreateChannelInput, slackCreateChannelOutput, slackDisconnectOutput } from "../slack/slack.contracts"; @@ -576,6 +577,98 @@ const appRouter = t.router({ .output(microsoftConnectionStatusOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), + proposals: t.router({ + access: publicProcedure + .output(accessOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + issuers: publicProcedure + .output(issuersOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + createIssuer: publicProcedure + .input(createIssuerInput) + .output(idInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + updateIssuer: publicProcedure + .input(updateIssuerInput) + .output(idInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + deleteIssuer: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + uploadTemplate: publicProcedure + .input(uploadTemplateInput) + .output(templateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archiveTemplate: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + downloadTemplate: publicProcedure + .input(idInput) + .output(fileOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + companyProfile: publicProcedure + .input(companyInput) + .output(companyProfileOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setCompanyIssuer: publicProcedure + .input(setCompanyIssuerInput) + .output(companyProfileOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setClientLogo: publicProcedure + .input(setClientLogoInput) + .output(companyProfileOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + removeClientLogo: publicProcedure + .input(companyInput) + .output(companyProfileOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + services: publicProcedure + .input(listServicesInput) + .output(servicesOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + service: publicProcedure + .input(idInput) + .output(serviceOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + saveService: publicProcedure + .input(saveServiceInput) + .output(serviceOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archiveService: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + restoreService: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + forDeal: publicProcedure + .input(dealInput) + .output(dealProposalsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + draft: publicProcedure + .input(draftInput) + .output(draftOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + generate: publicProcedure + .input(generateInput) + .output(generateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + download: publicProcedure + .input(downloadInput) + .output(fileOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + uploadFinal: publicProcedure + .input(uploadFinalInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setStatus: publicProcedure + .input(setStatusInput) + .output(setStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), savedViews: t.router({ list: publicProcedure .input(savedViewListInput) diff --git a/apps/api/src/proposals/catalog.service.ts b/apps/api/src/proposals/catalog.service.ts new file mode 100644 index 000000000..616b1bb1c --- /dev/null +++ b/apps/api/src/proposals/catalog.service.ts @@ -0,0 +1,146 @@ +import type { Db, JsonValue, Prisma } from "@crm/db"; +import { + type ProposalExtra, + parseProposalContent, + parseProposalExtras, +} from "@crm/validation/proposal"; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import type { z } from "zod"; +import { InjectDatabase } from "../database/database.constants"; +import { prismaCode } from "./prisma-code"; +import type { ServiceOutput, saveServiceInput } from "./proposals.contracts"; + +const SERVICE_INCLUDE = { + prices: { + include: { issuer: { select: { name: true } } }, + orderBy: { issuer: { name: "asc" } }, + }, +} as const satisfies Prisma.ServiceInclude; + +type ServiceRow = Prisma.ServiceGetPayload<{ include: typeof SERVICE_INCLUDE }>; + +function extrasOf(value: JsonValue): ProposalExtra[] { + try { + return parseProposalExtras(value); + } catch { + return []; + } +} + +function summary(row: ServiceRow) { + return { + id: row.id, + name: row.name, + archived: row.archivedAt !== null, + updatedAt: row.updatedAt.toISOString(), + prices: row.prices.map((price) => ({ + issuerId: price.issuerId, + issuerName: price.issuer.name, + amount: price.amount.toFixed(2), + extras: extrasOf(price.extras), + })), + }; +} + +@Injectable() +export class CatalogService { + private readonly logger = new Logger(CatalogService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async list(archived: boolean) { + const rows = await this.db.service.findMany({ + where: { archivedAt: archived ? { not: null } : null }, + include: SERVICE_INCLUDE, + orderBy: { name: "asc" }, + }); + return rows.map(summary); + } + + async byId(id: string): Promise { + const row = await this.db.service.findUnique({ + where: { id }, + include: SERVICE_INCLUDE, + }); + if (!row) throw new NotFoundException(`No service with id ${id}.`); + return { ...summary(row), content: parseProposalContent(row.content) }; + } + + async save(input: z.infer): Promise { + const id = await this.write(input); + + this.logger.log({ + message: input.id ? "Service updated" : "Service created", + serviceId: id, + prices: input.prices.length, + }); + + return this.byId(id); + } + + private async write( + input: z.infer, + ): Promise { + try { + return await this.db.$transaction(async (tx) => { + const service = input.id + ? await tx.service.update({ + where: { id: input.id }, + data: { name: input.name, content: input.content }, + select: { id: true }, + }) + : await tx.service.create({ + data: { name: input.name, content: input.content }, + select: { id: true }, + }); + + await tx.servicePrice.deleteMany({ where: { serviceId: service.id } }); + + if (input.prices.length > 0) { + await tx.servicePrice.createMany({ + data: input.prices.map((price) => ({ + serviceId: service.id, + issuerId: price.issuerId, + amount: price.amount, + extras: price.extras, + })), + }); + } + + return service.id; + }); + } catch (cause) { + const code = prismaCode(cause); + if (code === "P2025") { + throw new NotFoundException(`No service with id ${input.id}.`); + } + if (code === "P2002") { + throw new BadRequestException( + "Each issuer can have one price per service.", + ); + } + if (code === "P2003") { + throw new BadRequestException( + "A price points at an issuer that does not exist.", + ); + } + throw cause; + } + } + + async setArchived(id: string, archived: boolean) { + const updated = await this.db.service.updateMany({ + where: { id }, + data: { archivedAt: archived ? new Date() : null }, + }); + if (updated.count === 0) { + throw new NotFoundException(`No service with id ${id}.`); + } + return { ok: true as const }; + } +} diff --git a/apps/api/src/proposals/issuers.service.ts b/apps/api/src/proposals/issuers.service.ts new file mode 100644 index 000000000..36de782d2 --- /dev/null +++ b/apps/api/src/proposals/issuers.service.ts @@ -0,0 +1,383 @@ +import type { Db, JsonValue } from "@crm/db"; +import { + type ProposalPayment, + parseProposalPayments, + readProposalLogoSlot, +} from "@crm/validation/proposal"; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import type { z } from "zod"; +import { InjectDatabase } from "../database/database.constants"; +import { prismaCode } from "./prisma-code"; +import { + imageSize, + inspectTemplate, + type LogoSlot, + TemplateError, +} from "./proposal-docx"; +import type { + CompanyProfileOutput, + createIssuerInput, + IssuerOutput, + setClientLogoInput, + TemplateOutput, + updateIssuerInput, + uploadTemplateInput, +} from "./proposals.contracts"; + +const TEMPLATE_SELECT = { + id: true, + name: true, + fileName: true, + size: true, + markers: true, + logoSlot: true, + createdAt: true, + archivedAt: true, +} as const; + +type TemplateRow = { + id: string; + name: string; + fileName: string; + size: number; + markers: string[]; + logoSlot: JsonValue; + createdAt: Date; + archivedAt: Date | null; +}; + +function toTemplate(row: TemplateRow): TemplateOutput { + return { + id: row.id, + name: row.name, + fileName: row.fileName, + size: row.size, + markers: row.markers, + logoSlot: readProposalLogoSlot(row.logoSlot), + createdAt: row.createdAt.toISOString(), + archived: row.archivedAt !== null, + }; +} + +function decode(contentBase64: string): Buffer { + return Buffer.from(contentBase64, "base64"); +} + +export function toBytes(buffer: Buffer): Uint8Array { + return new Uint8Array(buffer); +} + +@Injectable() +export class IssuersService { + private readonly logger = new Logger(IssuersService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async list(): Promise { + const rows = await this.db.issuer.findMany({ + orderBy: { name: "asc" }, + include: { + templates: { + select: TEMPLATE_SELECT, + orderBy: { createdAt: "desc" }, + }, + _count: { select: { companies: true, proposals: true } }, + }, + }); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + numberPrefix: row.numberPrefix, + defaultPayments: this.payments(row.defaultPayments), + active: row.active, + clients: row._count.companies, + proposals: row._count.proposals, + templates: row.templates.map(toTemplate), + })); + } + + private payments(value: JsonValue): ProposalPayment[] { + try { + return parseProposalPayments(value); + } catch { + return []; + } + } + + async create(input: z.infer) { + try { + const issuer = await this.db.issuer.create({ + data: { + name: input.name, + numberPrefix: input.numberPrefix, + defaultPayments: input.defaultPayments, + }, + select: { id: true }, + }); + this.logger.log({ message: "Issuer created", issuerId: issuer.id }); + return issuer; + } catch (cause) { + if (prismaCode(cause) === "P2002") { + throw new ConflictException(`An issuer named ${input.name} exists.`); + } + throw cause; + } + } + + async update(input: z.infer) { + try { + return await this.db.issuer.update({ + where: { id: input.id }, + data: { + name: input.name, + numberPrefix: input.numberPrefix, + defaultPayments: input.defaultPayments, + active: input.active, + }, + select: { id: true }, + }); + } catch (cause) { + if (prismaCode(cause) === "P2002") { + throw new ConflictException(`An issuer named ${input.name} exists.`); + } + if (prismaCode(cause) === "P2025") { + throw new NotFoundException(`No issuer with id ${input.id}.`); + } + throw cause; + } + } + + async remove(id: string) { + const issuer = await this.db.issuer.findUnique({ + where: { id }, + select: { + _count: { + select: { + companies: true, + proposals: true, + prices: true, + templates: true, + }, + }, + }, + }); + + if (!issuer) throw new NotFoundException(`No issuer with id ${id}.`); + + const { companies, proposals, prices, templates } = issuer._count; + if (companies + proposals + prices + templates > 0) { + throw new ConflictException( + "This issuer is in use by clients, prices, templates or proposals. Deactivate it instead.", + ); + } + + await this.db.issuer.delete({ where: { id } }); + return { ok: true as const }; + } + + async uploadTemplate( + userId: string, + input: z.infer, + ): Promise { + if (!input.fileName.toLowerCase().endsWith(".docx")) { + throw new BadRequestException( + "Only Word documents (.docx) are accepted.", + ); + } + + const issuer = await this.db.issuer.findUnique({ + where: { id: input.issuerId }, + select: { id: true }, + }); + if (!issuer) { + throw new NotFoundException(`No issuer with id ${input.issuerId}.`); + } + + const content = decode(input.contentBase64); + const inspection = this.inspect(content); + + if (inspection.unknown.length > 0) { + throw new BadRequestException( + `The template uses markers this CRM does not know: ${inspection.unknown + .map((marker) => `{${marker}}`) + .join(", ")}.`, + ); + } + + const row = await this.db.issuerTemplate.create({ + data: { + issuerId: input.issuerId, + name: input.name, + fileName: input.fileName, + content: toBytes(content), + size: content.length, + markers: inspection.markers, + logoSlot: inspection.logoSlot ?? undefined, + uploadedById: userId, + }, + select: TEMPLATE_SELECT, + }); + + this.logger.log({ + message: "Proposal template uploaded", + templateId: row.id, + issuerId: input.issuerId, + markers: inspection.markers.length, + logoSlot: inspection.logoSlot !== null, + }); + + return toTemplate(row); + } + + private inspect(content: Buffer) { + try { + return inspectTemplate(content); + } catch (cause) { + if (cause instanceof TemplateError) { + throw new BadRequestException( + [cause.message, ...cause.details].join(" "), + ); + } + throw cause; + } + } + + async archiveTemplate(id: string) { + const updated = await this.db.issuerTemplate.updateMany({ + where: { id, archivedAt: null }, + data: { archivedAt: new Date() }, + }); + if (updated.count === 0) { + const exists = await this.db.issuerTemplate.count({ where: { id } }); + if (!exists) throw new NotFoundException(`No template with id ${id}.`); + } + return { ok: true as const }; + } + + async downloadTemplate(id: string) { + const row = await this.db.issuerTemplate.findUnique({ + where: { id }, + select: { fileName: true, content: true }, + }); + if (!row) throw new NotFoundException(`No template with id ${id}.`); + return { + fileName: row.fileName, + contentBase64: Buffer.from(row.content).toString("base64"), + }; + } + + async companyProfile(companyId: string): Promise { + const company = await this.db.company.findUnique({ + where: { id: companyId }, + select: { + id: true, + issuerId: true, + issuer: { select: { name: true } }, + proposalLogo: { select: { content: true, width: true, height: true } }, + }, + }); + + if (!company) + throw new NotFoundException(`No company with id ${companyId}.`); + + const proposals = await this.db.proposal.count({ + where: { deal: { companyId } }, + }); + + return { + companyId: company.id, + issuerId: company.issuerId, + issuerName: company.issuer?.name ?? null, + proposals, + logo: company.proposalLogo + ? { + contentBase64: Buffer.from(company.proposalLogo.content).toString( + "base64", + ), + width: company.proposalLogo.width, + height: company.proposalLogo.height, + } + : null, + }; + } + + async setCompanyIssuer(companyId: string, issuerId: string | null) { + if (issuerId) { + const issuer = await this.db.issuer.findUnique({ + where: { id: issuerId }, + select: { active: true }, + }); + if (!issuer) + throw new NotFoundException(`No issuer with id ${issuerId}.`); + if (!issuer.active) { + throw new BadRequestException("That issuer is deactivated."); + } + } + + try { + await this.db.company.update({ + where: { id: companyId }, + data: { issuerId }, + select: { id: true }, + }); + } catch (cause) { + if (prismaCode(cause) === "P2025") { + throw new NotFoundException(`No company with id ${companyId}.`); + } + throw cause; + } + + this.logger.log({ message: "Company issuer set", companyId, issuerId }); + return this.companyProfile(companyId); + } + + async setClientLogo( + userId: string, + input: z.infer, + ) { + const content = decode(input.contentBase64); + const size = imageSize(content); + + if (!size || content[1] !== 0x50) { + throw new BadRequestException("The logo must be a PNG image."); + } + + const exists = await this.db.company.count({ + where: { id: input.companyId }, + }); + if (!exists) { + throw new NotFoundException(`No company with id ${input.companyId}.`); + } + + await this.db.clientLogo.upsert({ + where: { companyId: input.companyId }, + create: { + companyId: input.companyId, + content: toBytes(content), + width: size.width, + height: size.height, + uploadedById: userId, + }, + update: { + content: toBytes(content), + width: size.width, + height: size.height, + uploadedById: userId, + }, + }); + + return this.companyProfile(input.companyId); + } + + async removeClientLogo(companyId: string) { + await this.db.clientLogo.deleteMany({ where: { companyId } }); + return this.companyProfile(companyId); + } +} diff --git a/apps/api/src/proposals/prisma-code.ts b/apps/api/src/proposals/prisma-code.ts new file mode 100644 index 000000000..de49f044d --- /dev/null +++ b/apps/api/src/proposals/prisma-code.ts @@ -0,0 +1,7 @@ +import { Prisma } from "@crm/db"; + +export function prismaCode(cause: unknown): string | null { + return cause instanceof Prisma.PrismaClientKnownRequestError + ? cause.code + : null; +} diff --git a/apps/api/src/proposals/proposal-access.service.ts b/apps/api/src/proposals/proposal-access.service.ts new file mode 100644 index 000000000..6c7c106d5 --- /dev/null +++ b/apps/api/src/proposals/proposal-access.service.ts @@ -0,0 +1,21 @@ +import { canManageProposals, workspaceRoleOf } from "@crm/auth"; +import type { Db } from "@crm/db"; +import { ForbiddenException, Injectable } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; + +@Injectable() +export class ProposalAccessService { + constructor(@InjectDatabase() private readonly db: Db) {} + + async canManage(userId: string): Promise { + return canManageProposals(await workspaceRoleOf(userId, this.db)); + } + + async requireOwner(userId: string): Promise { + if (!(await this.canManage(userId))) { + throw new ForbiddenException( + "Only the workspace owner can use proposals.", + ); + } + } +} diff --git a/apps/api/src/proposals/proposal-data.ts b/apps/api/src/proposals/proposal-data.ts new file mode 100644 index 000000000..a0adf0102 --- /dev/null +++ b/apps/api/src/proposals/proposal-data.ts @@ -0,0 +1,138 @@ +import type { + ProposalContent, + ProposalExtra, + ProposalLine, + ProposalPayment, +} from "@crm/validation/proposal"; +import { + formatEur, + formatProposalDate, + splitByPercent, + toCents, +} from "./proposal-format"; + +type ProposalDocument = { + date: Date; + number: string; + clientName: string; + attentionName: string; + attentionPosition: string; + jurisdictionText: string; + content: ProposalContent; + lines: ProposalLine[]; + extras: ProposalExtra[]; + payments: ProposalPayment[]; +}; + +export const PROPOSAL_MARKERS = [ + "date", + "number", + "client_name", + "attention_name", + "attention_position", + "jurisdiction_text", + "service_title", + "service_pitch", + "challenge", + "risks", + "objective", + "scope", + "title", + "description", + "methodology_intro", + "phases", + "name", + "steps", + "compliance", + "deliverables", + "items", + "out_of_scope", + "schedule", + "phase", + "duration", + "effort_note", + "requirements", + "test_accounts", + "lines", + "amount", + "total", + "included", + "extras", + "label", + "price", + "unit", + "payments", + "percent", +] as const; + +export function unitSuffix(unit: string): string { + const trimmed = unit.trim(); + return /^[\p{L}\p{N}]/u.test(trimmed) ? ` ${trimmed}` : trimmed; +} + +export function proposalTotalCents(lines: ProposalLine[]): number { + return lines.reduce((sum, line) => sum + toCents(line.amount), 0); +} + +export function templateData(doc: ProposalDocument) { + const { content } = doc; + const totalCents = proposalTotalCents(doc.lines); + const shares = splitByPercent( + totalCents, + doc.payments.map((payment) => payment.percent), + ); + + return { + date: formatProposalDate(doc.date), + number: doc.number, + client_name: doc.clientName, + attention_name: doc.attentionName, + attention_position: doc.attentionPosition, + jurisdiction_text: doc.jurisdictionText, + service_title: content.title, + service_pitch: content.pitch, + challenge: content.challenge, + risks: content.risks, + objective: content.objective, + scope: content.scope.map((item) => ({ + title: item.title, + description: item.description, + })), + methodology_intro: content.methodologyIntro, + phases: content.phases.map((phase) => ({ + name: phase.name, + steps: phase.steps, + })), + compliance: content.compliance, + deliverables: content.deliverables.map((group) => ({ + name: group.name, + items: group.items, + })), + out_of_scope: content.outOfScope, + schedule: content.schedule.map((row) => ({ + phase: row.phase, + duration: row.duration, + })), + effort_note: content.effortNote, + requirements: content.requirements, + test_accounts: content.testAccounts, + lines: doc.lines.map((line) => ({ + description: line.description, + amount: formatEur(line.amount), + })), + total: formatEur(totalCents / 100), + included: content.included, + extras: doc.extras.map((extra) => ({ + label: extra.label, + price: formatEur(extra.price), + unit: unitSuffix(extra.unit), + })), + payments: doc.payments.map((payment, index) => ({ + percent: payment.percent, + label: payment.label, + amount: formatEur((shares[index] ?? 0) / 100), + })), + }; +} + +export type TemplateData = ReturnType; diff --git a/apps/api/src/proposals/proposal-docx.ts b/apps/api/src/proposals/proposal-docx.ts new file mode 100644 index 000000000..b5012671a --- /dev/null +++ b/apps/api/src/proposals/proposal-docx.ts @@ -0,0 +1,232 @@ +import Docxtemplater from "docxtemplater"; +import InspectModule from "docxtemplater/js/inspect-module.js"; +import PizZip from "pizzip"; +import { z } from "zod"; +import { PROPOSAL_MARKERS, type TemplateData } from "./proposal-data"; +import { PROPOSALS } from "./proposals.config"; + +const TRANSPARENT_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==", + "base64", +); + +const KNOWN_MARKERS = new Set(PROPOSAL_MARKERS); + +export type LogoSlot = { width: number; height: number }; + +type TemplateInspection = { + markers: string[]; + unknown: string[]; + logoSlot: LogoSlot | null; +}; + +export class TemplateError extends Error { + constructor( + message: string, + readonly details: string[], + ) { + super(message); + } +} + +function openZip(file: Buffer): PizZip { + try { + return new PizZip(file); + } catch { + throw new TemplateError("The file is not a Word document (.docx).", []); + } +} + +function compile(zip: PizZip, modules: InspectModule[] = []): Docxtemplater { + if (!zip.file("word/document.xml")) { + throw new TemplateError("The file is not a Word document (.docx).", []); + } + + try { + return new Docxtemplater(zip, { + paragraphLoop: true, + linebreaks: true, + nullGetter: () => "", + errorLogging: false, + modules, + }); + } catch (cause) { + throw new TemplateError( + "The template has broken markers.", + templateErrorDetails(cause), + ); + } +} + +const templateFault = z.object({ + message: z.string().optional(), + properties: z.object({ explanation: z.string().optional() }).optional(), +}); + +const templateFailure = templateFault.extend({ + properties: z + .object({ + explanation: z.string().optional(), + errors: z.array(templateFault).optional(), + }) + .optional(), +}); + +function templateErrorDetails(cause: unknown): string[] { + const parsed = templateFailure.safeParse(cause); + if (!parsed.success) return [String(cause)]; + + const nested = parsed.data.properties?.errors ?? []; + const faults = nested.length > 0 ? nested : [parsed.data]; + + return faults.map( + (fault) => + fault.properties?.explanation ?? fault.message ?? "Unknown error.", + ); +} + +type TagTree = { [marker: string]: TagTree }; + +const tagTree: z.ZodType = z.lazy(() => z.record(z.string(), tagTree)); + +function flattenTags(tags: TagTree, into: Set) { + for (const [key, value] of Object.entries(tags)) { + if (key !== ".") into.add(key); + flattenTags(value, into); + } +} + +type LogoTarget = { path: string; extentRatio: number }; + +function logoTargets(zip: PizZip): LogoTarget[] { + const document = zip.file("word/document.xml")?.asText() ?? ""; + const rels = zip.file("word/_rels/document.xml.rels")?.asText() ?? ""; + const descr = new RegExp( + `]*descr="${PROPOSALS.logo.slotDescr}"`, + ); + const targets = new Map(); + + for (const drawing of document.matchAll( + /[\s\S]*?<\/w:drawing>/g, + )) { + if (!descr.test(drawing[0])) continue; + + const extent = drawing[0].match(/ 0 + ? Number(extent[1]) / Number(extent[2]) + : 1; + + for (const embed of drawing[0].matchAll(/r:embed="([^"]+)"/g)) { + const rel = rels.match( + new RegExp(`Id="${embed[1]}"[^>]*Target="([^"]+)"`), + ); + if (!rel?.[1]) continue; + const path = `word/${rel[1].replace(/^\.?\//, "")}`; + if (!targets.has(path)) targets.set(path, { path, extentRatio }); + } + } + + return [...targets.values()]; +} + +function pngSize(bytes: Uint8Array): LogoSlot | null { + const isPng = + bytes.length > 24 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47; + if (!isPng) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +function jpegSize(bytes: Uint8Array): LogoSlot | null { + if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + let offset = 2; + + while (offset + 9 < bytes.length) { + if (bytes[offset] !== 0xff) return null; + const marker = bytes[offset + 1] ?? 0; + const length = ((bytes[offset + 2] ?? 0) << 8) | (bytes[offset + 3] ?? 0); + if ( + marker >= 0xc0 && + marker <= 0xcf && + ![0xc4, 0xc8, 0xcc].includes(marker) + ) { + return { + height: ((bytes[offset + 5] ?? 0) << 8) | (bytes[offset + 6] ?? 0), + width: ((bytes[offset + 7] ?? 0) << 8) | (bytes[offset + 8] ?? 0), + }; + } + offset += 2 + length; + } + + return null; +} + +export function imageSize(bytes: Uint8Array): LogoSlot | null { + return pngSize(bytes) ?? jpegSize(bytes); +} + +function slotOf(zip: PizZip): LogoSlot | null { + const [first] = logoTargets(zip); + if (!first) return null; + + const media = zip.file(first.path)?.asUint8Array(); + const size = media ? imageSize(media) : null; + if (size && size.width > 0 && size.height > 0) return size; + + return { width: Math.round(first.extentRatio * 1000), height: 1000 }; +} + +export function inspectTemplate(file: Buffer): TemplateInspection { + const zip = openZip(file); + const inspect = new InspectModule(); + compile(zip, [inspect]); + + const notPng = logoTargets(zip).filter( + (target) => !target.path.toLowerCase().endsWith(".png"), + ); + if (notPng.length > 0) { + throw new TemplateError( + "The client logo image in the template must be a PNG.", + notPng.map((target) => target.path), + ); + } + + const found = new Set(); + flattenTags(tagTree.parse(inspect.getAllTags()), found); + const markers = [...found].sort(); + + return { + markers, + unknown: markers.filter((marker) => !KNOWN_MARKERS.has(marker)), + logoSlot: slotOf(zip), + }; +} + +export function renderProposal( + file: Buffer, + data: TemplateData, + logo: Buffer | null, +): Buffer { + const doc = compile(openZip(file)); + + try { + doc.render(data); + } catch (cause) { + throw new TemplateError( + "The template could not be filled.", + templateErrorDetails(cause), + ); + } + + const zip = doc.getZip(); + for (const target of logoTargets(zip)) { + zip.file(target.path, logo ?? TRANSPARENT_PNG); + } + + return zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); +} diff --git a/apps/api/src/proposals/proposal-format.ts b/apps/api/src/proposals/proposal-format.ts new file mode 100644 index 000000000..fdcf26364 --- /dev/null +++ b/apps/api/src/proposals/proposal-format.ts @@ -0,0 +1,53 @@ +import { PROPOSALS } from "./proposals.config"; + +export function toCents(amount: string | number): number { + const value = Number(amount); + + if (!Number.isFinite(value)) { + throw new Error(`"${amount}" is not an amount.`); + } + + return Math.round(value * 100); +} + +export function centsToDecimal(cents: number): string { + const sign = cents < 0 ? "-" : ""; + const abs = Math.abs(cents); + return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`; +} + +export function formatEur(amount: string | number): string { + const { thousands, decimal, currencySuffix } = PROPOSALS.format; + const cents = toCents(amount); + const sign = cents < 0 ? "-" : ""; + const abs = Math.abs(cents); + const whole = String(Math.floor(abs / 100)).replace( + /\B(?=(\d{3})+(?!\d))/g, + thousands, + ); + const fraction = abs % 100; + const tail = + fraction === 0 ? "" : `${decimal}${String(fraction).padStart(2, "0")}`; + + return `${sign}${whole}${tail}${currencySuffix}`; +} + +export function formatProposalDate(date: Date): string { + const day = String(date.getUTCDate()).padStart(2, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + return `${day}-${month}-${date.getUTCFullYear()}`; +} + +export function splitByPercent( + totalCents: number, + percents: number[], +): number[] { + let allotted = 0; + + return percents.map((percent, index) => { + if (index === percents.length - 1) return totalCents - allotted; + const share = Math.round((totalCents * percent) / 100); + allotted += share; + return share; + }); +} diff --git a/apps/api/src/proposals/proposals.config.ts b/apps/api/src/proposals/proposals.config.ts new file mode 100644 index 000000000..26b5abd59 --- /dev/null +++ b/apps/api/src/proposals/proposals.config.ts @@ -0,0 +1,25 @@ +const KB = 1024; +const MB = 1024 * KB; + +export const PROPOSALS = { + files: { + maxBytes: 3 * MB, + docxMediaType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + pngMediaType: "image/png", + }, + logo: { + maxBytes: 1 * MB, + slotDescr: "client_logo", + }, + format: { + thousands: ".", + decimal: ",", + currencySuffix: "€", + currency: "EUR", + }, + jurisdictions: { + PT: "Lei do Cibercrime (Lei n.º 109/2009) and applicable Portuguese and EU law", + ES: "applicable Spanish and EU law", + }, +} as const; diff --git a/apps/api/src/proposals/proposals.contracts.ts b/apps/api/src/proposals/proposals.contracts.ts new file mode 100644 index 000000000..739c6daf2 --- /dev/null +++ b/apps/api/src/proposals/proposals.contracts.ts @@ -0,0 +1,288 @@ +import { + eurAmount, + proposalContent, + proposalExtras, + proposalJurisdiction, + proposalLines, + proposalPayments, +} from "@crm/validation/proposal"; +import { z } from "zod"; +import { PROPOSALS } from "./proposals.config"; + +const id = z.string().trim().min(1); + +const base64 = (maxBytes: number) => + z + .string() + .min(1) + .max(Math.ceil((maxBytes * 4) / 3) + 4, "The file is too large.") + .regex( + /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/, + "The file content must be valid base64.", + ); + +const fileName = z.string().trim().min(1).max(200); + +const PROPOSAL_STATUSES = ["DRAFT", "SENT", "ACCEPTED", "REJECTED"] as const; + +const DEAL_STAGE_SUGGESTIONS = ["CONTRACT_SENT", "CLOSED_WON"] as const; + +export const accessOutput = z.object({ + canManage: z.boolean(), + maxFileBytes: z.number(), + maxLogoBytes: z.number(), +}); + +const issuerInput = z.object({ + name: z.string().trim().min(1).max(120), + numberPrefix: z.string().trim().max(40).default(""), + defaultPayments: proposalPayments, +}); + +export const createIssuerInput = issuerInput; + +export const updateIssuerInput = issuerInput.extend({ + id, + active: z.boolean(), +}); + +export const idInput = z.object({ id }); + +const logoSlotOutput = z + .object({ width: z.number(), height: z.number() }) + .nullable(); + +export const templateOutput = z.object({ + id: z.string(), + name: z.string(), + fileName: z.string(), + size: z.number(), + markers: z.array(z.string()), + logoSlot: logoSlotOutput, + createdAt: z.string(), + archived: z.boolean(), +}); + +export type TemplateOutput = z.infer; + +const issuerOutput = z.object({ + id: z.string(), + name: z.string(), + numberPrefix: z.string(), + defaultPayments: z.array( + z.object({ percent: z.number(), label: z.string() }), + ), + active: z.boolean(), + clients: z.number(), + proposals: z.number(), + templates: z.array(templateOutput), +}); + +export type IssuerOutput = z.infer; + +export const issuersOutput = z.array(issuerOutput); + +export const uploadTemplateInput = z.object({ + issuerId: id, + name: z.string().trim().min(1).max(120), + fileName, + contentBase64: base64(PROPOSALS.files.maxBytes), +}); + +export const fileOutput = z.object({ + fileName: z.string(), + contentBase64: z.string(), +}); + +export const companyInput = z.object({ companyId: id }); + +export const companyProfileOutput = z.object({ + companyId: z.string(), + issuerId: z.string().nullable(), + issuerName: z.string().nullable(), + proposals: z.number(), + logo: z + .object({ + contentBase64: z.string(), + width: z.number(), + height: z.number(), + }) + .nullable(), +}); + +export type CompanyProfileOutput = z.infer; + +export const setCompanyIssuerInput = z.object({ + companyId: id, + issuerId: id.nullable(), +}); + +export const setClientLogoInput = z.object({ + companyId: id, + contentBase64: base64(PROPOSALS.logo.maxBytes), +}); + +const priceInput = z.object({ + issuerId: id, + amount: eurAmount, + extras: proposalExtras, +}); + +export const saveServiceInput = z.object({ + id: id.optional(), + name: z.string().trim().min(1).max(200), + content: proposalContent, + prices: z.array(priceInput).max(20), +}); + +const priceOutput = z.object({ + issuerId: z.string(), + issuerName: z.string(), + amount: z.string(), + extras: z.array( + z.object({ label: z.string(), price: z.string(), unit: z.string() }), + ), +}); + +const serviceSummaryOutput = z.object({ + id: z.string(), + name: z.string(), + archived: z.boolean(), + prices: z.array(priceOutput), + updatedAt: z.string(), +}); + +export const servicesOutput = z.array(serviceSummaryOutput); + +export const serviceOutput = serviceSummaryOutput.extend({ + content: proposalContent, +}); + +export type ServiceOutput = z.infer; + +export const listServicesInput = z.object({ + archived: z.boolean().default(false), +}); + +export const dealInput = z.object({ dealId: id }); + +const versionOutput = z.object({ + id: z.string(), + version: z.number(), + number: z.string(), + date: z.string(), + status: z.enum(PROPOSAL_STATUSES), + statusChangedAt: z.string(), + total: z.string(), + issuerName: z.string(), + templateName: z.string(), + generatedFileName: z.string(), + finalFileName: z.string().nullable(), + finalUploadedAt: z.string().nullable(), + createdAt: z.string(), + createdByName: z.string().nullable(), +}); + +export type VersionOutput = z.infer; + +export const dealProposalsOutput = z.object({ + companyId: z.string(), + companyName: z.string(), + issuer: z.object({ id: z.string(), name: z.string() }).nullable(), + hasLogo: z.boolean(), + versions: z.array(versionOutput), +}); + +export const draftInput = z.object({ + dealId: id, + serviceId: id.optional(), + fromProposalId: id.optional(), +}); + +const draftTemplateOutput = z.object({ + id: z.string(), + name: z.string(), + logoSlot: logoSlotOutput, +}); + +export const draftOutput = z.object({ + dealId: z.string(), + serviceId: z.string().nullable(), + issuer: z.object({ id: z.string(), name: z.string() }), + clientName: z.string(), + templates: z.array(draftTemplateOutput), + contacts: z.array( + z.object({ id: z.string(), name: z.string(), title: z.string() }), + ), + number: z.string(), + date: z.string(), + attentionContactId: z.string().nullable(), + attentionName: z.string(), + attentionPosition: z.string(), + jurisdiction: proposalJurisdiction, + jurisdictionText: z.string(), + jurisdictionTexts: z.object({ PT: z.string(), ES: z.string() }), + content: proposalContent, + lines: proposalLines, + extras: proposalExtras, + payments: proposalPayments, +}); + +export type DraftOutput = z.infer; + +export const generateInput = z.object({ + dealId: id, + serviceId: id.nullable(), + templateId: id, + number: z.string().trim().min(1).max(120), + date: z.iso.date(), + attentionContactId: id.nullable(), + attentionName: z.string().trim().max(200).default(""), + attentionPosition: z.string().trim().max(200).default(""), + jurisdiction: proposalJurisdiction, + jurisdictionText: z.string().trim().min(1).max(1000), + content: proposalContent, + lines: proposalLines, + extras: proposalExtras, + payments: proposalPayments, + logoBase64: base64(PROPOSALS.logo.maxBytes).nullable(), +}); + +export type GenerateInput = z.infer; + +export const generateOutput = z.object({ + id: z.string(), + version: z.number(), + total: z.string(), + fileName: z.string(), + contentBase64: z.string(), +}); + +export const downloadInput = z.object({ + id, + which: z.enum(["preferred", "generated", "final"]).default("preferred"), +}); + +export const uploadFinalInput = z.object({ + id, + fileName, + contentBase64: base64(PROPOSALS.files.maxBytes), +}); + +export const setStatusInput = z.object({ + id, + status: z.enum(PROPOSAL_STATUSES), +}); + +export const setStatusOutput = z.object({ + id: z.string(), + dealId: z.string(), + status: z.enum(PROPOSAL_STATUSES), + suggestion: z.object({ + stage: z.enum(DEAL_STAGE_SUGGESTIONS).nullable(), + amountCents: z.number().nullable(), + currency: z.literal(PROPOSALS.format.currency).nullable(), + }), +}); + +export const okOutput = z.object({ ok: z.literal(true) }); diff --git a/apps/api/src/proposals/proposals.module.ts b/apps/api/src/proposals/proposals.module.ts new file mode 100644 index 000000000..5477fbda7 --- /dev/null +++ b/apps/api/src/proposals/proposals.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { CatalogService } from "./catalog.service"; +import { IssuersService } from "./issuers.service"; +import { ProposalAccessService } from "./proposal-access.service"; +import { ProposalsRouter } from "./proposals.router"; +import { ProposalsService } from "./proposals.service"; + +@Module({ + imports: [TrpcModule], + providers: [ + ProposalAccessService, + IssuersService, + CatalogService, + ProposalsService, + ProposalsRouter, + ], +}) +export class ProposalsModule {} diff --git a/apps/api/src/proposals/proposals.router.ts b/apps/api/src/proposals/proposals.router.ts new file mode 100644 index 000000000..db9f0f073 --- /dev/null +++ b/apps/api/src/proposals/proposals.router.ts @@ -0,0 +1,355 @@ +import { Inject } from "@nestjs/common"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; +import { CatalogService } from "./catalog.service"; +import { IssuersService } from "./issuers.service"; +import { ProposalAccessService } from "./proposal-access.service"; +import { PROPOSALS } from "./proposals.config"; +import { + accessOutput, + companyInput, + companyProfileOutput, + createIssuerInput, + dealInput, + dealProposalsOutput, + downloadInput, + draftInput, + draftOutput, + fileOutput, + generateInput, + generateOutput, + idInput, + issuersOutput, + listServicesInput, + okOutput, + saveServiceInput, + serviceOutput, + servicesOutput, + setClientLogoInput, + setCompanyIssuerInput, + setStatusInput, + setStatusOutput, + templateOutput, + updateIssuerInput, + uploadFinalInput, + uploadTemplateInput, +} from "./proposals.contracts"; +import { ProposalsService } from "./proposals.service"; + +const TAGS = ["Proposals"]; + +@Router({ alias: "proposals" }) +@UseMiddlewares(AuthMiddleware) +export class ProposalsRouter { + constructor( + @Inject(ProposalAccessService) + private readonly gate: ProposalAccessService, + @Inject(IssuersService) private readonly issuerService: IssuersService, + @Inject(CatalogService) private readonly catalog: CatalogService, + @Inject(ProposalsService) private readonly proposals: ProposalsService, + ) {} + + @Query({ + output: accessOutput, + meta: restMeta("GET", "/proposals/access", TAGS), + }) + async access(@Ctx() ctx: AuthedTrpcContext) { + return { + canManage: await this.gate.canManage(ctx.user.id), + maxFileBytes: PROPOSALS.files.maxBytes, + maxLogoBytes: PROPOSALS.logo.maxBytes, + }; + } + + @Query({ + output: issuersOutput, + meta: restMeta("GET", "/proposals/issuers", TAGS), + }) + async issuers(@Ctx() ctx: AuthedTrpcContext) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.list(); + } + + @Mutation({ + input: createIssuerInput, + output: idInput, + meta: restMeta("POST", "/proposals/issuers", TAGS), + }) + async createIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.create(input); + } + + @Mutation({ + input: updateIssuerInput, + output: idInput, + meta: restMeta("PATCH", "/proposals/issuers/{id}", TAGS), + }) + async updateIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.update(input); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("DELETE", "/proposals/issuers/{id}", TAGS), + }) + async deleteIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.remove(input.id); + } + + @Mutation({ + input: uploadTemplateInput, + output: templateOutput, + meta: restMeta("POST", "/proposals/templates", TAGS), + }) + async uploadTemplate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.uploadTemplate(ctx.user.id, input); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("POST", "/proposals/templates/{id}/archive", TAGS), + }) + async archiveTemplate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.archiveTemplate(input.id); + } + + @Query({ + input: idInput, + output: fileOutput, + meta: restMeta("GET", "/proposals/templates/{id}/file", TAGS), + }) + async downloadTemplate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.downloadTemplate(input.id); + } + + @Query({ + input: companyInput, + output: companyProfileOutput, + meta: restMeta("GET", "/proposals/companies/{companyId}", TAGS), + }) + async companyProfile( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.companyProfile(input.companyId); + } + + @Mutation({ + input: setCompanyIssuerInput, + output: companyProfileOutput, + meta: restMeta("PUT", "/proposals/companies/{companyId}/issuer", TAGS), + }) + async setCompanyIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.setCompanyIssuer(input.companyId, input.issuerId); + } + + @Mutation({ + input: setClientLogoInput, + output: companyProfileOutput, + meta: restMeta("PUT", "/proposals/companies/{companyId}/logo", TAGS), + }) + async setClientLogo( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.setClientLogo(ctx.user.id, input); + } + + @Mutation({ + input: companyInput, + output: companyProfileOutput, + meta: restMeta("DELETE", "/proposals/companies/{companyId}/logo", TAGS), + }) + async removeClientLogo( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.removeClientLogo(input.companyId); + } + + @Query({ + input: listServicesInput, + output: servicesOutput, + meta: restMeta("GET", "/proposals/services", TAGS), + }) + async services( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.list(input.archived); + } + + @Query({ + input: idInput, + output: serviceOutput, + meta: restMeta("GET", "/proposals/services/{id}", TAGS), + }) + async service( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.byId(input.id); + } + + @Mutation({ + input: saveServiceInput, + output: serviceOutput, + meta: restMeta("PUT", "/proposals/services", TAGS), + }) + async saveService( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.save(input); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("POST", "/proposals/services/{id}/archive", TAGS), + }) + async archiveService( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.setArchived(input.id, true); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("POST", "/proposals/services/{id}/restore", TAGS), + }) + async restoreService( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.setArchived(input.id, false); + } + + @Query({ + input: dealInput, + output: dealProposalsOutput, + meta: restMeta("GET", "/proposals/deals/{dealId}", TAGS), + }) + async forDeal( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.forDeal(input.dealId); + } + + @Query({ + input: draftInput, + output: draftOutput, + meta: restMeta("GET", "/proposals/deals/{dealId}/draft", TAGS), + }) + async draft( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.draft(input); + } + + @Mutation({ + input: generateInput, + output: generateOutput, + meta: restMeta("POST", "/proposals/deals/{dealId}/generate", TAGS), + }) + async generate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.generate(ctx.user.id, input); + } + + @Query({ + input: downloadInput, + output: fileOutput, + meta: restMeta("GET", "/proposals/{id}/file", TAGS), + }) + async download( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.download(input.id, input.which); + } + + @Mutation({ + input: uploadFinalInput, + output: okOutput, + meta: restMeta("PUT", "/proposals/{id}/final", TAGS), + }) + async uploadFinal( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.uploadFinal(input); + } + + @Mutation({ + input: setStatusInput, + output: setStatusOutput, + meta: restMeta("PATCH", "/proposals/{id}/status", TAGS), + }) + async setStatus( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.setStatus(input.id, input.status); + } +} diff --git a/apps/api/src/proposals/proposals.service.ts b/apps/api/src/proposals/proposals.service.ts new file mode 100644 index 000000000..633dba7fa --- /dev/null +++ b/apps/api/src/proposals/proposals.service.ts @@ -0,0 +1,564 @@ +import type { Db, JsonValue, ProposalStatus } from "@crm/db"; +import { + type ProposalContent, + type ProposalJurisdiction, + parseProposalContent, + parseProposalExtras, + parseProposalLines, + parseProposalPayments, + readProposalLogoSlot, +} from "@crm/validation/proposal"; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { toBytes } from "./issuers.service"; +import { prismaCode } from "./prisma-code"; +import { + proposalTotalCents, + type TemplateData, + templateData, +} from "./proposal-data"; +import { imageSize, renderProposal, TemplateError } from "./proposal-docx"; +import { centsToDecimal } from "./proposal-format"; +import { PROPOSALS } from "./proposals.config"; +import type { + DraftOutput, + GenerateInput, + VersionOutput, +} from "./proposals.contracts"; + +const VERSION_SELECT = { + id: true, + version: true, + number: true, + date: true, + status: true, + statusChangedAt: true, + total: true, + issuerName: true, + templateName: true, + generatedFileName: true, + finalFileName: true, + finalUploadedAt: true, + createdAt: true, + createdById: true, +} as const; + +function stageFor( + status: ProposalStatus, +): "CONTRACT_SENT" | "CLOSED_WON" | null { + if (status === "SENT") return "CONTRACT_SENT"; + if (status === "ACCEPTED") return "CLOSED_WON"; + return null; +} + +function isoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function contactName(contact: { + firstName: string; + lastName: string | null; +}): string { + return [contact.firstName, contact.lastName].filter(Boolean).join(" "); +} + +function safeFileName(value: string): string { + return value + .replace(/[\\/:*?"<>|]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 150); +} + +function defaultJurisdiction(countryCode: string | null): ProposalJurisdiction { + return countryCode?.toUpperCase() === "ES" ? "ES" : "PT"; +} + +function isPng(bytes: Buffer): boolean { + return bytes[1] === 0x50 && imageSize(bytes) !== null; +} + +@Injectable() +export class ProposalsService { + private readonly logger = new Logger(ProposalsService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async forDeal(dealId: string) { + const deal = await this.db.deal.findUnique({ + where: { id: dealId }, + select: { + company: { + select: { + id: true, + name: true, + issuer: { select: { id: true, name: true } }, + proposalLogo: { select: { companyId: true } }, + }, + }, + proposals: { select: VERSION_SELECT, orderBy: { version: "desc" } }, + }, + }); + + if (!deal) throw new NotFoundException(`No deal with id ${dealId}.`); + + return { + companyId: deal.company.id, + companyName: deal.company.name, + issuer: deal.company.issuer, + hasLogo: deal.company.proposalLogo !== null, + versions: await this.toVersions(deal.proposals), + }; + } + + private async toVersions( + rows: Array<{ + id: string; + version: number; + number: string; + date: Date; + status: ProposalStatus; + statusChangedAt: Date; + total: { toFixed(digits: number): string }; + issuerName: string; + templateName: string; + generatedFileName: string; + finalFileName: string | null; + finalUploadedAt: Date | null; + createdAt: Date; + createdById: string; + }>, + ): Promise { + const authorIds = [...new Set(rows.map((row) => row.createdById))]; + const authors = await this.db.user.findMany({ + where: { id: { in: authorIds } }, + select: { id: true, name: true }, + }); + const nameOf = new Map(authors.map((author) => [author.id, author.name])); + + return rows.map((row) => ({ + id: row.id, + version: row.version, + number: row.number, + date: isoDate(row.date), + status: row.status, + statusChangedAt: row.statusChangedAt.toISOString(), + total: row.total.toFixed(2), + issuerName: row.issuerName, + templateName: row.templateName, + generatedFileName: row.generatedFileName, + finalFileName: row.finalFileName, + finalUploadedAt: row.finalUploadedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + createdByName: nameOf.get(row.createdById) ?? null, + })); + } + + private async dealContext(dealId: string) { + const deal = await this.db.deal.findUnique({ + where: { id: dealId }, + select: { + id: true, + company: { + select: { + name: true, + countryCode: true, + issuer: { + select: { + id: true, + name: true, + active: true, + numberPrefix: true, + defaultPayments: true, + templates: { + where: { archivedAt: null }, + select: { id: true, name: true, logoSlot: true }, + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + contacts: { + select: { + contact: { + select: { + id: true, + firstName: true, + lastName: true, + title: true, + }, + }, + }, + orderBy: { contact: { firstName: "asc" } }, + }, + }, + }); + + if (!deal) throw new NotFoundException(`No deal with id ${dealId}.`); + + const issuer = deal.company.issuer; + if (!issuer) { + throw new BadRequestException( + "Set the issuer of this client before creating a proposal.", + ); + } + if (!issuer.active) { + throw new BadRequestException( + `The issuer ${issuer.name} is deactivated. Choose another issuer for this client.`, + ); + } + + return { deal, issuer }; + } + + async draft(input: { + dealId: string; + serviceId?: string; + fromProposalId?: string; + }): Promise { + const { deal, issuer } = await this.dealContext(input.dealId); + const contacts = deal.contacts.map(({ contact }) => ({ + id: contact.id, + name: contactName(contact), + title: contact.title ?? "", + })); + const first = contacts[0]; + + const base = { + dealId: deal.id, + issuer: { id: issuer.id, name: issuer.name }, + clientName: deal.company.name, + templates: issuer.templates.map((template) => ({ + id: template.id, + name: template.name, + logoSlot: readProposalLogoSlot(template.logoSlot), + })), + contacts, + date: isoDate(new Date()), + jurisdictionTexts: { ...PROPOSALS.jurisdictions }, + }; + + if (input.fromProposalId) { + const previous = await this.db.proposal.findFirst({ + where: { id: input.fromProposalId, dealId: deal.id }, + }); + if (!previous) { + throw new NotFoundException( + `No proposal with id ${input.fromProposalId} on this deal.`, + ); + } + + return { + ...base, + serviceId: previous.serviceId, + number: previous.number, + attentionContactId: previous.attentionContactId, + attentionName: previous.attentionName, + attentionPosition: previous.attentionPosition, + jurisdiction: previous.jurisdiction, + jurisdictionText: previous.jurisdictionText, + content: parseProposalContent(previous.content), + lines: parseProposalLines(previous.lines), + extras: parseProposalExtras(previous.extras), + payments: parseProposalPayments(previous.payments), + }; + } + + if (!input.serviceId) { + throw new BadRequestException("Choose a service or a previous version."); + } + + const service = await this.db.service.findUnique({ + where: { id: input.serviceId }, + select: { + id: true, + archivedAt: true, + content: true, + prices: { + where: { issuerId: issuer.id }, + select: { amount: true, extras: true }, + }, + }, + }); + + if (!service || service.archivedAt) { + throw new NotFoundException(`No service with id ${input.serviceId}.`); + } + + const price = service.prices[0]; + if (!price) { + throw new BadRequestException( + `This service has no price for ${issuer.name}.`, + ); + } + + const content: ProposalContent = parseProposalContent(service.content); + const jurisdiction = defaultJurisdiction(deal.company.countryCode); + + return { + ...base, + serviceId: service.id, + number: issuer.numberPrefix, + attentionContactId: first?.id ?? null, + attentionName: first?.name ?? "", + attentionPosition: first?.title ?? "", + jurisdiction, + jurisdictionText: PROPOSALS.jurisdictions[jurisdiction], + content, + lines: [{ description: content.title, amount: price.amount.toFixed(2) }], + extras: parseProposalExtras(price.extras), + payments: this.issuerPayments(issuer.defaultPayments), + }; + } + + private issuerPayments(value: JsonValue) { + try { + return parseProposalPayments(value); + } catch { + return [{ percent: 100, label: "upon acceptance" }]; + } + } + + async generate(userId: string, input: GenerateInput) { + const { deal, issuer } = await this.dealContext(input.dealId); + + const template = await this.db.issuerTemplate.findFirst({ + where: { id: input.templateId, issuerId: issuer.id, archivedAt: null }, + select: { id: true, name: true, content: true, logoSlot: true }, + }); + if (!template) { + throw new BadRequestException( + `That template does not belong to ${issuer.name}.`, + ); + } + + if ( + input.attentionContactId && + !deal.contacts.some( + ({ contact }) => contact.id === input.attentionContactId, + ) + ) { + throw new BadRequestException("The A/C contact is not on this deal."); + } + + const logo = input.logoBase64 + ? Buffer.from(input.logoBase64, "base64") + : null; + if (logo && !isPng(logo)) { + throw new BadRequestException("The client logo must be a PNG image."); + } + + const date = new Date(`${input.date}T00:00:00.000Z`); + const totalCents = proposalTotalCents(input.lines); + const file = this.render( + Buffer.from(template.content), + templateData({ + date, + number: input.number, + clientName: deal.company.name, + attentionName: input.attentionName, + attentionPosition: input.attentionPosition, + jurisdictionText: input.jurisdictionText, + content: input.content, + lines: input.lines, + extras: input.extras, + payments: input.payments, + }), + readProposalLogoSlot(template.logoSlot) ? logo : null, + ); + + const created = await this.insertVersion(deal.id, (version) => ({ + dealId: deal.id, + version, + issuerId: issuer.id, + issuerName: issuer.name, + templateId: template.id, + templateName: template.name, + serviceId: input.serviceId, + number: input.number, + date, + attentionContactId: input.attentionContactId, + attentionName: input.attentionName, + attentionPosition: input.attentionPosition, + jurisdiction: input.jurisdiction, + jurisdictionText: input.jurisdictionText, + content: input.content, + lines: input.lines, + extras: input.extras, + payments: input.payments, + total: centsToDecimal(totalCents), + generatedFile: toBytes(file), + generatedFileName: `${safeFileName( + `${input.number} ${deal.company.name} v${version}`, + )}.docx`, + createdById: userId, + })); + + this.logger.log({ + message: "Proposal generated", + proposalId: created.id, + dealId: deal.id, + version: created.version, + issuerId: issuer.id, + templateId: template.id, + bytes: file.length, + }); + + return { + id: created.id, + version: created.version, + total: centsToDecimal(totalCents), + fileName: created.generatedFileName, + contentBase64: file.toString("base64"), + }; + } + + private render( + template: Buffer, + data: TemplateData, + logo: Buffer | null, + ): Buffer { + try { + return renderProposal(template, data, logo); + } catch (cause) { + if (cause instanceof TemplateError) { + throw new BadRequestException( + [cause.message, ...cause.details].join(" "), + ); + } + throw cause; + } + } + + private async insertVersion< + Data extends Parameters[0]["data"], + >(dealId: string, build: (version: number) => Data) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const last = await this.db.proposal.aggregate({ + where: { dealId }, + _max: { version: true }, + }); + const version = (last._max.version ?? 0) + 1; + + try { + return await this.db.proposal.create({ + data: build(version), + select: { id: true, version: true, generatedFileName: true }, + }); + } catch (cause) { + if (prismaCode(cause) !== "P2002") throw cause; + } + } + + throw new ConflictException( + "Another version was saved at the same time. Try again.", + ); + } + + async download(id: string, which: "preferred" | "generated" | "final") { + const row = await this.db.proposal.findUnique({ + where: { id }, + select: { + generatedFile: true, + generatedFileName: true, + finalFile: true, + finalFileName: true, + }, + }); + if (!row) throw new NotFoundException(`No proposal with id ${id}.`); + + const wantsFinal = + which === "final" || (which === "preferred" && row.finalFile !== null); + + if (wantsFinal) { + if (!row.finalFile || !row.finalFileName) { + throw new NotFoundException("This version has no final file."); + } + return { + fileName: row.finalFileName, + contentBase64: Buffer.from(row.finalFile).toString("base64"), + }; + } + + return { + fileName: row.generatedFileName, + contentBase64: Buffer.from(row.generatedFile).toString("base64"), + }; + } + + async uploadFinal(input: { + id: string; + fileName: string; + contentBase64: string; + }) { + if (!input.fileName.toLowerCase().endsWith(".docx")) { + throw new BadRequestException( + "Only Word documents (.docx) are accepted.", + ); + } + + const content = Buffer.from(input.contentBase64, "base64"); + if (content[0] !== 0x50 || content[1] !== 0x4b) { + throw new BadRequestException("The file is not a Word document (.docx)."); + } + + try { + await this.db.proposal.update({ + where: { id: input.id }, + data: { + finalFile: toBytes(content), + finalFileName: safeFileName(input.fileName), + finalUploadedAt: new Date(), + }, + select: { id: true }, + }); + } catch (cause) { + if (prismaCode(cause) === "P2025") { + throw new NotFoundException(`No proposal with id ${input.id}.`); + } + throw cause; + } + + return { ok: true as const }; + } + + async setStatus(id: string, status: ProposalStatus) { + const current = await this.db.proposal.findUnique({ + where: { id }, + select: { status: true }, + }); + if (!current) throw new NotFoundException(`No proposal with id ${id}.`); + + const row = await this.db.proposal.update({ + where: { id }, + data: + current.status === status + ? {} + : { status, statusChangedAt: new Date() }, + select: { id: true, dealId: true, status: true, total: true }, + }); + + const stage = stageFor(status); + const offersAmount = status === "ACCEPTED"; + + return { + id: row.id, + dealId: row.dealId, + status: row.status, + suggestion: { + stage, + amountCents: offersAmount + ? Math.round(row.total.toNumber() * 100) + : null, + currency: offersAmount ? PROPOSALS.format.currency : null, + }, + }; + } +} diff --git a/apps/api/test/fixtures/broken-loop.docx b/apps/api/test/fixtures/broken-loop.docx new file mode 100644 index 0000000000000000000000000000000000000000..847d2dcc191677078fd6fb30894142a1ec8c51b1 GIT binary patch literal 36591 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 F^?!?VF~k4> literal 0 HcmV?d00001 diff --git a/apps/api/test/fixtures/unknown-marker.docx b/apps/api/test/fixtures/unknown-marker.docx new file mode 100644 index 0000000000000000000000000000000000000000..7af353b4336e5ea55c0b2dcfc2328ae28bc3b844 GIT binary patch literal 36588 zcmagFWmp}_wm*yqcXxuj1b4S!!6mr6yE_C81b26LcL|!{1b25fu+jg{%$aj%&bjw} zzcde3YyD(Zbyv|{D1C&4!UO{Yg9VeU5YVksF8Z1T4hA*`0|tfxYSj_5w{tPIbJ17z zbTD<+W%97KX-bk;ToFbMyLiD$W)>v%7DK@*-*%vJVEBqJUXuaf)>>jdlY;=>Pw+;n z{ZtBvVfvbxcJE8PQZLZ$(@do!CVr-?o2gOo9C2a;wzcVqVx{Y+z?D{ zYo-t0G$%OAPVE4%K6>bdXs&ipk|q!=mbxlV9i3!26joVa3_7Bw;3rpJC%J>F^6@D3 z4!yFhhv!fvglhC?R9jk(A_h8>kz8i*+a-|jW;{K+>;e`dW%kN ztyooG%i~~ae6M>k1>^GRX!p21NZl|MR62@z@-Si`?*?AFhy}_6?zb;XWV+ zR^LlMcNOost_0;9%P}O=>bxWo6{7WgHGJ}ZL@24i6Bq1zUMyML6B@gJpZ|m@uLq{| zk5Tlb`Y8MW8ohN0FfatrPkkp-8)s&w-`A>yNm)o%)PM^?iBZZDyJj`ZF47>li~iv<>G!MXE)`_33P-kxN6$647oETxc|0 zeHmx%VeBR4nmN9dRJHBexPN&NYIcz}2oOGwcR*7~Q!ru0;Vwj$am2V4lR21F-INl`yU_ zz=A4q?;|lMuO4}W3M(N)2Y&oTRvI&KsqK!PL*8aQn{#qwr_feAm)?zdgKy-%oL-pAQ%Dsx`rvEHap_=xbe*^n|B zth#aw5xnGs^&ISTVN+yTt;)rDhoUNI^2=fB!U;6(z~$TQ*Q`_|@8q+lT8qtoSRaiXKp}fL=^B3!k^NV-CNb!Hou%XJ2`GlyFuz!Ahv`fA}8bJ{X8qJ z6b{ORUDa`0w;wF38=0HiUS)Kq3=ncLY5h=(@zOlbO`T%sm${aQJmM~39uAlF?f&-4 zk~QO-3}%T)CX`IO^bAAyWd31;|C`h+-V!1G1J@N4?y)(t9__5V*z0ruHNii}<#uHS z=L9q^Hc((-sDB?9LkEXHhDCMUZiNN4^NAix*X4`lJCRSg2|)`YKgHJYYZEeC=EmO3 zeuZjd-S+bZMqu%TF#{s`8u*?X$DS5N%-%IBSg2u*1M^3f9M|BRYQBu@uS_1icZMp5 zF2hnELeq8BpB}54hhc{LaByk$&3c7qakrqjzzKf`iYGXH#K` zQ@6T@!OJA2rls}NPb`XGDj;8KEGj%prv*5qb^ym($w^4ak`RV3AE3rPD=cZ2BP4O; zNeNgDx-KUypsWzQT>$kaM`*d`wmLZcv*f}j#0(DCvxpCa@Vhw1g`2{DKLC;r6Y0@cIR6v;$SMkECE#p0W0tmt!ZUHyyloV}rVOsg2o)(xb#-PiKQj|w!esz&2;SxarzHQJ7>dn@)~KMH zl?Svg{k~z{?VU`RL3gdIEogE2Ju`|_KgRuHLG8ZR5O3RsattVDe}5W?RUx1%x^~va z7MrL^AY?+ItPSXKKc$E-N6#N0%0A%A88NOQ_PeCwR<=#W^!z6G2~$bLq)$9aYD2Fj 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 bq%4NoIt=h;1?C`7t`cNO1ZI!YE)Wj@BWWM5 literal 0 HcmV?d00001 diff --git a/apps/api/test/proposal-docx.spec.ts b/apps/api/test/proposal-docx.spec.ts new file mode 100644 index 000000000..db28f59f5 --- /dev/null +++ b/apps/api/test/proposal-docx.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import PizZip from "pizzip"; +import { templateData } from "../src/proposals/proposal-data"; +import { + inspectTemplate, + renderProposal, + TemplateError, +} from "../src/proposals/proposal-docx"; + +const fixture = (name: string) => + readFileSync(join(import.meta.dir, "fixtures", name)); + +const LOGO = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAEUlEQVR4nGP4z8DwnwEIGAEA/wH/eKDUAAAAAElFTkSuQmCC", + "base64", +); + +function documentText(file: Buffer): string { + const zip = new PizZip(file); + return (zip.file("word/document.xml")?.asText() ?? "") + .replace(/]/g, "\n$&") + .replace(/<[^>]+>/g, ""); +} + +function logoBytes(file: Buffer): Buffer { + const zip = new PizZip(file); + const media = Object.keys(zip.files).find((name) => + name.startsWith("word/media/"), + ); + return Buffer.from(zip.file(media ?? "")?.asUint8Array() ?? []); +} + +const data = templateData({ + date: new Date("2026-09-29T00:00:00.000Z"), + number: "MMN-TST-0001", + clientName: "Acme Lda", + attentionName: "Jane Doe", + attentionPosition: "CISO", + jurisdictionText: "applicable Spanish and EU law", + content: { + title: "Web Application Penetration Test", + pitch: "a comprehensive web application penetration test", + challenge: "", + risks: [], + objective: "", + scope: [], + methodologyIntro: "", + phases: [ + { name: "PHASE 1: Recon", steps: ["Passive recon", "Mapping"] }, + { name: "PHASE 2: Scan", steps: ["Scanning"] }, + { name: "PHASE 3: Exploit", steps: ["Exploitation"] }, + { name: "PHASE 4: Report", steps: ["Reporting"] }, + ], + compliance: "", + deliverables: [], + outOfScope: "", + schedule: [ + { phase: "Recon", duration: "Days 1-2" }, + { phase: "Scanning", duration: "Days 2-4" }, + { phase: "Testing", duration: "Days 4-10" }, + { phase: "Report", duration: "Days 11-14" }, + { phase: "Delivery", duration: "Day 14" }, + ], + effortNote: "", + requirements: [], + testAccounts: "", + included: [], + }, + lines: [{ description: "Web Application Penetration Test", amount: "5200" }], + extras: [], + payments: [ + { percent: 50, label: "deposit" }, + { percent: 50, label: "final payment" }, + ], +}); + +describe("templateData", () => { + it("computes the total and the instalment amounts", () => { + expect(data.total).toBe("5.200€"); + expect(data.payments.map((p) => p.amount)).toEqual(["2.600€", "2.600€"]); + expect(data.date).toBe("29-09-2026"); + }); +}); + +describe("renderProposal", () => { + const out = renderProposal(fixture("proposal-template.docx"), data, LOGO); + const text = documentText(out); + + it("fills fields and leaves no marker behind", () => { + expect(text).toContain("29-09-2026 MMN-TST-0001"); + expect(text).toContain( + "For Acme Lda, we propose a comprehensive web application penetration test.", + ); + expect(text).not.toContain("{"); + expect(text).not.toContain("}"); + }); + + it("repeats nested loops and table rows", () => { + expect(text.match(/PHASE \d/g)).toHaveLength(4); + expect(text).toContain("Passive recon"); + expect(text).toContain("Days 11-14"); + expect(text.match(/Days? \d/g)).toHaveLength(5); + expect(text).toContain("50% deposit (2.600€)"); + expect(text).toMatch(/TOTAL\s+5\.200€/); + }); + + it("puts the client logo into the logo slot", () => { + expect(logoBytes(out).equals(LOGO)).toBe(true); + }); + + it("empties the logo slot when the client has no logo", () => { + const blank = renderProposal(fixture("proposal-template.docx"), data, null); + const bytes = logoBytes(blank); + expect(bytes.equals(LOGO)).toBe(false); + expect(bytes.subarray(1, 4).toString()).toBe("PNG"); + expect(bytes.length).toBeLessThan(100); + }); +}); + +describe("inspectTemplate", () => { + it("lists the markers and the logo slot", () => { + const inspection = inspectTemplate(fixture("proposal-template.docx")); + expect(inspection.unknown).toEqual([]); + expect(inspection.markers).toContain("phases"); + expect(inspection.markers).toContain("steps"); + expect(inspection.logoSlot).toEqual({ width: 300, height: 128 }); + }); + + it("reports a marker outside the contract", () => { + expect(inspectTemplate(fixture("unknown-marker.docx")).unknown).toEqual([ + "client_nme", + ]); + }); + + it("refuses a loop that is never closed", () => { + expect(() => inspectTemplate(fixture("broken-loop.docx"))).toThrow( + TemplateError, + ); + }); + + it("refuses a file that is not a Word document", () => { + expect(() => inspectTemplate(Buffer.from("%PDF-1.7"))).toThrow( + TemplateError, + ); + }); +}); diff --git a/apps/api/test/proposal-format.spec.ts b/apps/api/test/proposal-format.spec.ts new file mode 100644 index 000000000..0d50cd3b0 --- /dev/null +++ b/apps/api/test/proposal-format.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test"; +import { unitSuffix } from "../src/proposals/proposal-data"; +import { + centsToDecimal, + formatEur, + formatProposalDate, + splitByPercent, +} from "../src/proposals/proposal-format"; + +describe("formatEur", () => { + it("groups thousands with a dot and puts the euro sign after", () => { + expect(formatEur(10000)).toBe("10.000€"); + expect(formatEur("5200.00")).toBe("5.200€"); + expect(formatEur(1234567)).toBe("1.234.567€"); + }); + + it("shows cents only when they are not zero", () => { + expect(formatEur(1234.5)).toBe("1.234,50€"); + expect(formatEur("0.5")).toBe("0,50€"); + expect(formatEur(600)).toBe("600€"); + }); +}); + +describe("formatProposalDate", () => { + it("is day-month-year with dashes", () => { + expect(formatProposalDate(new Date("2026-09-29T00:00:00.000Z"))).toBe( + "29-09-2026", + ); + }); +}); + +describe("splitByPercent", () => { + it("splits a total into instalments", () => { + expect(splitByPercent(520000, [50, 50])).toEqual([260000, 260000]); + }); + + it("puts the rounding remainder on the last instalment", () => { + const parts = splitByPercent(10001, [33, 33, 34]); + expect(parts.reduce((a, b) => a + b, 0)).toBe(10001); + }); +}); + +describe("centsToDecimal", () => { + it("writes cents as a two-decimal string", () => { + expect(centsToDecimal(520000)).toBe("5200.00"); + expect(centsToDecimal(5)).toBe("0.05"); + }); +}); + +describe("unitSuffix", () => { + it("puts a space before a word and none before a slash", () => { + expect(unitSuffix("each")).toBe(" each"); + expect(unitSuffix(" each")).toBe(" each"); + expect(unitSuffix("/hour")).toBe("/hour"); + expect(unitSuffix("")).toBe(""); + }); +}); diff --git a/apps/api/test/proposals-db.integration.spec.ts b/apps/api/test/proposals-db.integration.spec.ts new file mode 100644 index 000000000..bb118adb7 --- /dev/null +++ b/apps/api/test/proposals-db.integration.spec.ts @@ -0,0 +1,114 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; + +const suffix = process.env.TEST_RUN_ID ?? "proposals-db-spec"; +const userId = `user-${suffix}`; + +let issuerId: string; +let companyId: string; + +async function proposalFor(dealId: string, version: number) { + return db.proposal.create({ + data: { + dealId, + version, + issuerId, + issuerName: "Spec issuer", + templateName: "Spec template", + number: `SPEC-${version}`, + date: new Date("2026-09-29"), + jurisdiction: "PT", + jurisdictionText: "applicable Portuguese and EU law", + content: { title: "Spec" }, + lines: [{ description: "Spec", amount: "100.00" }], + extras: [], + payments: [{ percent: 100, label: "upfront" }], + total: "100.00", + generatedFile: Buffer.from("docx"), + generatedFileName: "spec.docx", + createdById: userId, + }, + }); +} + +beforeAll(async () => { + await db.user.upsert({ + where: { id: userId }, + create: { id: userId, name: "Spec", email: `${suffix}@example.test` }, + update: {}, + }); + const issuer = await db.issuer.create({ + data: { name: `Issuer ${suffix}`, defaultPayments: [] }, + }); + issuerId = issuer.id; + const company = await db.company.create({ + data: { name: `Company ${suffix}`, issuerId }, + }); + companyId = company.id; +}, 30_000); + +afterAll(async () => { + await db.company.deleteMany({ where: { id: companyId } }); + await db.issuer.deleteMany({ where: { id: issuerId } }); + await db.user.deleteMany({ where: { id: userId } }); +}, 30_000); + +describe("proposal storage", () => { + it("deletes a deal's proposals with the deal", async () => { + const deal = await db.deal.create({ + data: { name: `Deal ${suffix}`, companyId, ownerId: userId }, + }); + await proposalFor(deal.id, 1); + await proposalFor(deal.id, 2); + + await db.deal.delete({ where: { id: deal.id } }); + + expect(await db.proposal.count({ where: { dealId: deal.id } })).toBe(0); + }); + + it("refuses two proposals with the same version on one deal", async () => { + const deal = await db.deal.create({ + data: { name: `Deal twin ${suffix}`, companyId, ownerId: userId }, + }); + await proposalFor(deal.id, 1); + + let code: string | undefined; + try { + await proposalFor(deal.id, 1); + } catch (error) { + code = (error as { code?: string }).code; + } + expect(code).toBe("P2002"); + + await db.deal.delete({ where: { id: deal.id } }); + }); + + it("refuses to delete an issuer that a client uses", async () => { + let code: string | undefined; + try { + await db.issuer.delete({ where: { id: issuerId } }); + } catch (error) { + code = (error as { code?: string }).code; + } + expect(code).toBe("P2003"); + }); + + it("keeps the client logo out of an ordinary company read", async () => { + await db.clientLogo.create({ + data: { + companyId, + content: Buffer.from("png"), + width: 300, + height: 128, + uploadedById: userId, + }, + }); + + const company = await db.company.findUniqueOrThrow({ + where: { id: companyId }, + }); + + expect(Object.keys(company)).not.toContain("proposalLogo"); + expect(JSON.stringify(company)).not.toContain("png"); + }); +}); diff --git a/apps/api/test/proposals.integration.spec.ts b/apps/api/test/proposals.integration.spec.ts new file mode 100644 index 000000000..a240cb8b3 --- /dev/null +++ b/apps/api/test/proposals.integration.spec.ts @@ -0,0 +1,471 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { SETTINGS_ID, writeReportingCurrency } from "@crm/db/settings"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { ConversionService } from "../src/currency/conversion.service"; +import { DealsService } from "../src/deals/deals.service"; +import { FieldsService } from "../src/fields/fields.service"; +import { CatalogService } from "../src/proposals/catalog.service"; +import { IssuersService } from "../src/proposals/issuers.service"; +import { ProposalAccessService } from "../src/proposals/proposal-access.service"; +import { uploadTemplateInput } from "../src/proposals/proposals.contracts"; +import { ProposalsService } from "../src/proposals/proposals.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "proposals-spec"; +const ownerId = `owner-${suffix}`; +const memberId = `member-${suffix}`; + +const access = new ProposalAccessService(db); +const issuers = new IssuersService(db); +const catalog = new CatalogService(db); +const proposals = new ProposalsService(db); +const deals = new DealsService( + db, + { withCrmEvents: withDiscardedCrmEvents } as unknown as AgentTriggerService, + new ActivityStampService(db), + new ConversionService(db), + new FieldsService(db, { fieldBackfill: async () => undefined } as never), +); + +const template = readFileSync( + join(import.meta.dir, "fixtures", "proposal-template.docx"), +).toString("base64"); +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAEUlEQVR4nGP4z8DwnwEIGAEA/wH/eKDUAAAAAElFTkSuQmCC", + "base64", +).toString("base64"); + +const payments = [ + { percent: 50, label: "deposit required to schedule testing" }, + { percent: 50, label: "final payment upon delivery" }, +]; + +let issuerB: string; +let issuerA: string; +let companyId: string; +let dealId: string; +let contactId: string; +let serviceId: string; +let templateId: string; +let previousReporting: string | null = null; + +async function codeOf(run: () => Promise): Promise { + try { + await run(); + return null; + } catch (error) { + return (error as { getStatus?: () => number }).getStatus?.() ?? -1; + } +} + +async function generateFrom( + draft: Awaited>, +) { + return proposals.generate(ownerId, { + dealId, + serviceId: draft.serviceId, + templateId, + number: draft.number, + date: draft.date, + attentionContactId: draft.attentionContactId, + attentionName: draft.attentionName, + attentionPosition: draft.attentionPosition, + jurisdiction: draft.jurisdiction, + jurisdictionText: draft.jurisdictionText, + content: draft.content, + lines: draft.lines, + extras: draft.extras, + payments: draft.payments, + logoBase64: PNG, + }); +} + +beforeAll(async () => { + const existing = await db.appSetting.findUnique({ + where: { id: SETTINGS_ID }, + select: { reportingCurrency: true }, + }); + previousReporting = existing?.reportingCurrency ?? null; + await writeReportingCurrency(db, "EUR"); + + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + create: { + id: WORKSPACE_ID, + name: "CRM", + slug: "crm", + createdAt: new Date(), + }, + update: {}, + }); + for (const [id, role] of [ + [ownerId, "owner"], + [memberId, "member"], + ] as const) { + await db.user.upsert({ + where: { id }, + create: { id, name: `User ${id}`, email: `${id}@example.test` }, + update: {}, + }); + await db.member.upsert({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId: id }, + }, + create: { + id: `m-${id}`, + organizationId: WORKSPACE_ID, + userId: id, + role, + createdAt: new Date(), + }, + update: { role }, + }); + } + + const company = await db.company.create({ + data: { name: `Acme ${suffix}`, countryCode: "ES" }, + }); + companyId = company.id; + const contact = await db.contact.create({ + data: { + firstName: "Jane", + lastName: "Doe", + title: "CISO", + email: `jane-${suffix}@example.test`, + companyId, + }, + }); + contactId = contact.id; + const deal = await db.deal.create({ + data: { + name: `Pentest ${suffix}`, + companyId, + ownerId, + currency: "EUR", + contacts: { create: { contactId } }, + }, + }); + dealId = deal.id; +}, 60_000); + +afterAll(async () => { + await db.deal.deleteMany({ where: { companyId } }); + await db.contact.deleteMany({ where: { companyId } }); + await db.company.deleteMany({ where: { id: companyId } }); + await db.servicePrice.deleteMany({ + where: { issuerId: { in: [issuerB, issuerA] } }, + }); + await db.service.deleteMany({ where: { name: { endsWith: suffix } } }); + await db.issuerTemplate.deleteMany({ + where: { issuerId: { in: [issuerB, issuerA] } }, + }); + await db.issuer.deleteMany({ where: { id: { in: [issuerB, issuerA] } } }); + await db.member.deleteMany({ + where: { userId: { in: [ownerId, memberId] } }, + }); + await db.user.deleteMany({ where: { id: { in: [ownerId, memberId] } } }); + if (previousReporting) await writeReportingCurrency(db, previousReporting); +}, 60_000); + +describe("access", () => { + it("lets the owner in and keeps a member out", async () => { + expect(await access.canManage(ownerId)).toBe(true); + expect(await access.canManage(memberId)).toBe(false); + expect(await codeOf(() => access.requireOwner(memberId))).toBe(403); + expect(await codeOf(() => access.requireOwner(ownerId))).toBeNull(); + }); +}); + +describe("issuers", () => { + it("creates issuers with default instalments", async () => { + issuerB = ( + await issuers.create({ + name: `Emissor B ${suffix}`, + numberPrefix: "MMN-", + defaultPayments: payments, + }) + ).id; + issuerA = ( + await issuers.create({ + name: `Emissor A ${suffix}`, + numberPrefix: "MMN-A-", + defaultPayments: payments, + }) + ).id; + + const listed = (await issuers.list()).find((row) => row.id === issuerB); + expect(listed?.defaultPayments).toEqual(payments); + }); + + it("refuses a second issuer with the same name", async () => { + expect( + await codeOf(() => + issuers.create({ + name: `Emissor B ${suffix}`, + numberPrefix: "", + defaultPayments: payments, + }), + ), + ).toBe(409); + }); +}); + +describe("templates", () => { + it("accepts a valid template and reports its markers and logo slot", async () => { + const row = await issuers.uploadTemplate(ownerId, { + issuerId: issuerB, + name: "Emissor B proposal", + fileName: "modelo-emissor-b.docx", + contentBase64: template, + }); + templateId = row.id; + expect(row.markers).toContain("phases"); + expect(row.logoSlot).toEqual({ width: 300, height: 128 }); + }); + + it("refuses a PDF", async () => { + expect( + await codeOf(() => + issuers.uploadTemplate(ownerId, { + issuerId: issuerB, + name: "PDF", + fileName: "gruposm.pdf", + contentBase64: template, + }), + ), + ).toBe(400); + }); + + it("refuses a template with an unknown marker", async () => { + const unknown = readFileSync( + join(import.meta.dir, "fixtures", "unknown-marker.docx"), + ).toString("base64"); + expect( + await codeOf(() => + issuers.uploadTemplate(ownerId, { + issuerId: issuerB, + name: "Bad", + fileName: "bad.docx", + contentBase64: unknown, + }), + ), + ).toBe(400); + }); + + it("refuses a file above the size limit before it reaches the service", () => { + const big = "A".repeat(5 * 1024 * 1024); + expect( + uploadTemplateInput.safeParse({ + issuerId: issuerB, + name: "Big", + fileName: "big.docx", + contentBase64: big, + }).success, + ).toBe(false); + }); +}); + +describe("client issuer and logo", () => { + it("asks for an issuer before a proposal", async () => { + expect( + await codeOf(() => proposals.draft({ dealId, serviceId: "x" })), + ).toBe(400); + }); + + it("sets the issuer and the logo of the client", async () => { + await issuers.setCompanyIssuer(companyId, issuerB); + const profile = await issuers.setClientLogo(ownerId, { + companyId, + contentBase64: PNG, + }); + expect(profile.issuerName).toBe(`Emissor B ${suffix}`); + expect(profile.logo?.width).toBe(2); + }); + + it("refuses a logo that is not a PNG", async () => { + expect( + await codeOf(() => + issuers.setClientLogo(ownerId, { + companyId, + contentBase64: Buffer.from("GIF89a....").toString("base64"), + }), + ), + ).toBe(400); + }); + + it("refuses to delete an issuer in use", async () => { + expect(await codeOf(() => issuers.remove(issuerB))).toBe(409); + }); +}); + +describe("catalog", () => { + it("keeps one price per issuer", async () => { + const saved = await catalog.save({ + name: `Web App Pentest ${suffix}`, + content: { + title: "Web Application Penetration Test", + pitch: "a comprehensive web application penetration test", + challenge: "", + risks: ["Breach"], + objective: "", + scope: [{ title: "Input Validation", description: "SQLi, XSS" }], + methodologyIntro: "", + phases: [{ name: "PHASE 1", steps: ["Recon"] }], + compliance: "", + deliverables: [], + outOfScope: "", + schedule: [{ phase: "Recon", duration: "Days 1-2" }], + effortNote: "", + requirements: [], + testAccounts: "", + included: [], + }, + prices: [ + { + issuerId: issuerB, + amount: "5200.00", + extras: [{ label: "Extra retests", price: "600", unit: " each" }], + }, + { issuerId: issuerA, amount: "6100.00", extras: [] }, + ], + }); + serviceId = saved.id; + expect(saved.prices.map((p) => p.amount).sort()).toEqual([ + "5200.00", + "6100.00", + ]); + }); + + it("hides a service without a price for the client's issuer", async () => { + const other = await catalog.save({ + name: `Emissor A only ${suffix}`, + content: { title: "Only Emissor A" } as never, + prices: [{ issuerId: issuerA, amount: "100.00", extras: [] }], + }); + expect( + await codeOf(() => proposals.draft({ dealId, serviceId: other.id })), + ).toBe(400); + }); +}); + +describe("proposals", () => { + it("drafts from the catalog with the issuer's price and defaults", async () => { + const draft = await proposals.draft({ dealId, serviceId }); + expect(draft.lines).toEqual([ + { description: "Web Application Penetration Test", amount: "5200.00" }, + ]); + expect(draft.number).toBe("MMN-"); + expect(draft.jurisdiction).toBe("ES"); + expect(draft.attentionName).toBe("Jane Doe"); + expect(draft.payments).toEqual(payments); + }); + + it("generates v1 and v2, and v1 stays as it was", async () => { + const draft = await proposals.draft({ dealId, serviceId }); + const v1 = await generateFrom({ ...draft, number: "MMN-001" }); + expect(v1.version).toBe(1); + expect(v1.total).toBe("5200.00"); + + const again = await proposals.draft({ dealId, fromProposalId: v1.id }); + expect(again.number).toBe("MMN-001"); + const v2 = await generateFrom({ + ...again, + lines: [{ description: "Web App", amount: "4800.00" }], + }); + expect(v2.version).toBe(2); + + const listed = await proposals.forDeal(dealId); + expect(listed.versions.map((v) => [v.version, v.total])).toEqual([ + [2, "4800.00"], + [1, "5200.00"], + ]); + expect(listed.versions[1]?.createdByName).toBe(`User ${ownerId}`); + }); + + it("keeps the generated file byte for byte after the template is archived", async () => { + const [latest] = (await proposals.forDeal(dealId)).versions; + const before = await proposals.download(latest?.id ?? "", "preferred"); + await issuers.archiveTemplate(templateId); + const after = await proposals.download(latest?.id ?? "", "preferred"); + expect(after.contentBase64).toBe(before.contentBase64); + }); + + it("prefers the uploaded final file and keeps the generated one", async () => { + const [latest] = (await proposals.forDeal(dealId)).versions; + const id = latest?.id ?? ""; + const generated = await proposals.download(id, "generated"); + await proposals.uploadFinal({ + id, + fileName: "final.docx", + contentBase64: template, + }); + + expect((await proposals.download(id, "preferred")).fileName).toBe( + "final.docx", + ); + expect((await proposals.download(id, "generated")).contentBase64).toBe( + generated.contentBase64, + ); + }); + + it("suggests the deal stage and amount, and leaves other versions alone", async () => { + const [v2, v1] = (await proposals.forDeal(dealId)).versions; + const sent = await proposals.setStatus(v2?.id ?? "", "SENT"); + expect(sent.suggestion).toEqual({ + stage: "CONTRACT_SENT", + amountCents: null, + currency: null, + }); + + const accepted = await proposals.setStatus(v2?.id ?? "", "ACCEPTED"); + expect(accepted.suggestion).toEqual({ + stage: "CLOSED_WON", + amountCents: 480000, + currency: "EUR", + }); + + const rejected = await proposals.setStatus(v1?.id ?? "", "REJECTED"); + expect(rejected.suggestion.stage).toBeNull(); + + const after = await proposals.forDeal(dealId); + expect(after.versions.map((v) => v.status)).toEqual([ + "ACCEPTED", + "REJECTED", + ]); + }); + + it("keeps the old issuer on old versions after the client changes issuer", async () => { + const profile = await issuers.companyProfile(companyId); + expect(profile.proposals).toBe(2); + + await issuers.setCompanyIssuer(companyId, issuerA); + const after = await proposals.forDeal(dealId); + expect(after.issuer?.id).toBe(issuerA); + expect( + after.versions.every((v) => v.issuerName === `Emissor B ${suffix}`), + ).toBe(true); + await issuers.setCompanyIssuer(companyId, issuerB); + }); + + it("updates the deal amount through the deals service, with conversion", async () => { + await deals.update(dealId, { amountCents: 480000, currency: "EUR" }); + const deal = await db.deal.findUniqueOrThrow({ + where: { id: dealId }, + select: { + amount: true, + currency: true, + baseAmount: true, + baseCurrency: true, + }, + }); + expect(deal.amount?.toFixed(2)).toBe("4800.00"); + expect(deal.currency).toBe("EUR"); + expect(deal.baseCurrency).toBe("EUR"); + expect(deal.baseAmount?.toFixed(2)).toBe("4800.00"); + }); +}); diff --git a/apps/app/app/(app)/[slug]/settings/proposals/issuers-panel.tsx b/apps/app/app/(app)/[slug]/settings/proposals/issuers-panel.tsx new file mode 100644 index 000000000..a5159d35e --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/issuers-panel.tsx @@ -0,0 +1,444 @@ +"use client"; + +import { Badge } from "@crm/ui/components/badge"; +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { + SimpleTable, + type SimpleTableColumn, + SimpleTableRow, +} from "@crm/ui/components/simple-table"; +import { Spinner } from "@crm/ui/components/spinner"; +import { Switch } from "@crm/ui/components/switch"; +import { TableCell } from "@crm/ui/components/table"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useRef, useState } from "react"; +import { toast } from "sonner"; +import { PaymentsEditor } from "@/components/crm/proposals/money-editors"; +import { PROPOSAL_UI } from "@/components/crm/proposals/proposal-config"; +import { + downloadBase64, + fileToBase64, +} from "@/components/crm/proposals/proposal-files"; +import type { ProposalPayment } from "@/components/crm/proposals/proposal-types"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; + +type Issuer = RouterOutputs["proposals"]["issuers"][number]; + +const DEFAULT_PAYMENTS: ProposalPayment[] = [ + { percent: 50, label: "deposit required to schedule testing" }, + { percent: 50, label: "final payment upon delivery" }, +]; + +const CELL = "px-3 py-2.5 align-middle"; + +const TEMPLATE_COLUMNS: SimpleTableColumn[] = [ + { id: "name", header: "Template" }, + { id: "markers", header: "Markers", width: "w-24", align: "right" }, + { id: "logo", header: "Client logo", width: "w-28" }, + { id: "actions", srLabel: "Actions", width: "w-44" }, +]; + +function megabytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; +} + +export function IssuersPanel({ maxFileBytes }: { maxFileBytes: number }) { + const trpc = useTRPC(); + const issuers = useQuery(trpc.proposals.issuers.queryOptions()); + + return ( +
+ {(issuers.data ?? []).map((issuer) => ( + + ))} + +
+ ); +} + +function NewIssuerCard() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const prefixId = useId(); + const [name, setName] = useState(""); + const [prefix, setPrefix] = useState("MMN-"); + const [payments, setPayments] = useState(DEFAULT_PAYMENTS); + + const create = useMutation( + trpc.proposals.createIssuer.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success(`${name} added.`); + setName(""); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + + + New issuer + + A company you issue proposals for. Each client belongs to one issuer. + + + +
{ + event.preventDefault(); + create.mutate({ + name, + numberPrefix: prefix, + defaultPayments: payments, + }); + }} + > + + + Name + setName(event.target.value)} + required + /> + + + Proposal number prefix + setPrefix(event.target.value)} + /> + + A new proposal's number starts with this. You complete it. + + + + +
+
+ + + +
+ ); +} + +function IssuerCard({ + issuer, + maxFileBytes, +}: { + issuer: Issuer; + maxFileBytes: number; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const prefixId = useId(); + const activeId = useId(); + const [name, setName] = useState(issuer.name); + const [prefix, setPrefix] = useState(issuer.numberPrefix); + const [active, setActive] = useState(issuer.active); + const [payments, setPayments] = useState( + issuer.defaultPayments.length > 0 + ? issuer.defaultPayments + : DEFAULT_PAYMENTS, + ); + + const update = useMutation( + trpc.proposals.updateIssuer.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success(`${name} saved.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const remove = useMutation( + trpc.proposals.deleteIssuer.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success(`${issuer.name} deleted.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const inUse = issuer.clients + issuer.proposals + issuer.templates.length > 0; + const formId = `issuer-${issuer.id}`; + + return ( + + + {issuer.name} + + {issuer.clients} {issuer.clients === 1 ? "client" : "clients"} ·{" "} + {issuer.proposals} {issuer.proposals === 1 ? "proposal" : "proposals"} + + + + {issuer.active ? "Active" : "Deactivated"} + + + + +
{ + event.preventDefault(); + update.mutate({ + id: issuer.id, + name, + numberPrefix: prefix, + active, + defaultPayments: payments, + }); + }} + > + + + Name + setName(event.target.value)} + required + /> + + + Proposal number prefix + setPrefix(event.target.value)} + /> + + + + Active + + + +
+ +
+ + + {inUse ? null : ( + + )} + +
+ ); +} + +function Templates({ + issuer, + maxFileBytes, +}: { + issuer: Issuer; + maxFileBytes: number; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const fileInput = useRef(null); + const [templateName, setTemplateName] = useState(""); + const [lastMarkers, setLastMarkers] = useState(null); + const templates = issuer.templates.filter((template) => !template.archived); + + const upload = useMutation( + trpc.proposals.uploadTemplate.mutationOptions({ + onSuccess: async (template) => { + await cache.proposals(); + setTemplateName(""); + setLastMarkers(template.markers); + toast.success(`${template.name} uploaded.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const archive = useMutation( + trpc.proposals.archiveTemplate.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Template archived."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const queryClient = useQueryClient(); + const download = async (id: string) => { + try { + const file = await queryClient.fetchQuery({ + ...trpc.proposals.downloadTemplate.queryOptions({ id }), + staleTime: 0, + }); + downloadBase64(file.fileName, file.contentBase64); + } catch (error) { + toast.error((error as Error).message); + } + }; + + const pick = async (file: File | undefined) => { + if (!file) return; + if (!file.name.toLowerCase().endsWith(".docx")) { + toast.error("Only Word documents (.docx) are accepted."); + return; + } + if (file.size > maxFileBytes) { + toast.error(`The file is larger than ${megabytes(maxFileBytes)}.`); + return; + } + upload.mutate({ + issuerId: issuer.id, + name: templateName.trim() || file.name.replace(/\.docx$/i, ""), + fileName: file.name, + contentBase64: await fileToBase64(file), + }); + }; + + return ( +
+

Word templates

+ {templates.length > 0 ? ( + + {templates.map((template) => ( + + +
+ {template.name} + + {template.fileName} + +
+
+ + {template.markers.length} + + + {template.logoSlot ? "Yes" : "No"} + + +
+ + +
+
+
+ ))} +
+ ) : ( +

+ No template yet. Upload the Word file with the markers from the marker + guide. +

+ )} +
+ + Template name + setTemplateName(event.target.value)} + /> + + { + void pick(event.target.files?.[0]); + event.target.value = ""; + }} + /> + +
+ {lastMarkers ? ( +

+ Markers found: {lastMarkers.map((marker) => `{${marker}}`).join(" ")} +

+ ) : null} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/page.tsx b/apps/app/app/(app)/[slug]/settings/proposals/page.tsx new file mode 100644 index 000000000..0775ee525 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellLoading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { ProposalSettings } from "./proposal-settings"; + +export const metadata: Metadata = { + title: "Proposals", +}; + +export default function ProposalsSettingsPage() { + return ( + + + + Proposals + + The companies you issue proposals for, their Word templates, and the + services you sell with their prices. + + + + + + }> + + + + + ); +} + +async function Proposals() { + await requireSession(); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + await queryClient.prefetchQuery(trpc.proposals.access.queryOptions()); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/proposal-settings.tsx b/apps/app/app/(app)/[slug]/settings/proposals/proposal-settings.tsx new file mode 100644 index 000000000..35f164c99 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/proposal-settings.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@crm/ui/components/tabs"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@/lib/trpc/client"; +import { IssuersPanel } from "./issuers-panel"; +import { ServicesPanel } from "./services-panel"; + +export function ProposalSettings() { + const trpc = useTRPC(); + const access = useQuery(trpc.proposals.access.queryOptions()); + + if (!access.data) return null; + + if (!access.data.canManage) { + return ( + + + Only the workspace owner uses proposals + + Issuers, templates, services and proposals are visible to the owner + of this workspace only. + + + + ); + } + + return ( + + + Issuers + Services + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/service-sheet.tsx b/apps/app/app/(app)/[slug]/settings/proposals/service-sheet.tsx new file mode 100644 index 000000000..7ac9ad1f7 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/service-sheet.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@crm/ui/components/sheet"; +import { Spinner } from "@crm/ui/components/spinner"; +import { Switch } from "@crm/ui/components/switch"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { ProposalContentEditor } from "@/components/crm/proposals/content-editor"; +import { ExtrasEditor } from "@/components/crm/proposals/money-editors"; +import { PROPOSAL_UI } from "@/components/crm/proposals/proposal-config"; +import { + cleanContent, + EMPTY_CONTENT, + type ProposalContent, + type ProposalExtra, +} from "@/components/crm/proposals/proposal-types"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +type IssuerOption = { id: string; name: string; active: boolean }; + +type PriceDraft = { + offered: boolean; + amount: string; + extras: ProposalExtra[]; +}; + +type ServiceDraft = { + name: string; + content: ProposalContent; + prices: Record; +}; + +export function ServiceSheet({ + serviceId, + onClose, +}: { + serviceId: string | null; + onClose: () => void; +}) { + const trpc = useTRPC(); + const issuers = useQuery(trpc.proposals.issuers.queryOptions()); + const service = useQuery({ + ...trpc.proposals.service.queryOptions({ id: serviceId ?? "" }), + enabled: serviceId !== null, + }); + + const ready = issuers.data && (serviceId === null || service.data); + const options: IssuerOption[] = (issuers.data ?? []).map((issuer) => ({ + id: issuer.id, + name: issuer.name, + active: issuer.active, + })); + + const initial: ServiceDraft | null = ready + ? { + name: service.data?.name ?? "", + content: service.data?.content ?? EMPTY_CONTENT, + prices: Object.fromEntries( + options.map((issuer) => { + const price = service.data?.prices.find( + (row) => row.issuerId === issuer.id, + ); + return [ + issuer.id, + { + offered: Boolean(price), + amount: price?.amount ?? "", + extras: price?.extras ?? [], + }, + ]; + }), + ), + } + : null; + + return ( + (open ? null : onClose())}> + + + {serviceId ? "Edit service" : "New service"} + + Proposals copy this text and price. Changing it later does not + change proposals already made. + + + {initial ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +} + +function ServiceForm({ + serviceId, + initial, + issuers, + onDone, +}: { + serviceId: string | null; + initial: ServiceDraft; + issuers: IssuerOption[]; + onDone: () => void; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const [draft, setDraft] = useState(initial); + + const save = useMutation( + trpc.proposals.saveService.mutationOptions({ + onSuccess: async (saved) => { + await cache.proposals(); + toast.success(`${saved.name} saved.`); + onDone(); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const setPrice = (issuerId: string, next: PriceDraft) => + setDraft({ ...draft, prices: { ...draft.prices, [issuerId]: next } }); + + return ( + <> +
{ + event.preventDefault(); + save.mutate({ + id: serviceId ?? undefined, + name: draft.name, + content: cleanContent(draft.content), + prices: Object.entries(draft.prices) + .filter(([, price]) => price.offered) + .map(([issuerId, price]) => ({ + issuerId, + amount: price.amount, + extras: price.extras.filter((extra) => extra.label.trim()), + })), + }); + }} + > + + + Catalog name + + setDraft({ ...draft, name: event.target.value }) + } + required + /> + + How you find it in the catalog. The document prints the service + title below. + + + + {issuers.map((issuer) => { + const price = draft.prices[issuer.id] ?? { + offered: false, + amount: "", + extras: [], + }; + return ( + setPrice(issuer.id, next)} + /> + ); + })} + + setDraft({ ...draft, content })} + /> + +
+ + + + + ); +} + +function PriceFields({ + issuer, + price, + onChange, +}: { + issuer: IssuerOption; + price: PriceDraft; + onChange: (next: PriceDraft) => void; +}) { + const offeredId = useId(); + const amountId = useId(); + + return ( +
+ + {issuer.name} + {issuer.active ? "" : " (deactivated)"} + +
+ + onChange({ ...price, offered })} + /> + + {issuer.name} offers this service + + + {price.offered ? ( + <> + + Price in euros + + onChange({ + ...price, + amount: event.target.value.replace(",", "."), + }) + } + required + /> + + onChange({ ...price, extras })} + /> + + ) : null} +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/services-panel.tsx b/apps/app/app/(app)/[slug]/settings/proposals/services-panel.tsx new file mode 100644 index 000000000..20b289044 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/services-panel.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { CardTableEmpty } from "@crm/ui/components/card-table"; +import { + SimpleTable, + type SimpleTableColumn, + SimpleTableRow, +} from "@crm/ui/components/simple-table"; +import { Switch } from "@crm/ui/components/switch"; +import { TableCell } from "@crm/ui/components/table"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { formatEur } from "@/components/crm/proposals/proposal-types"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import { ServiceSheet } from "./service-sheet"; + +const CELL = "px-3 py-2.5 align-middle"; + +const COLUMNS: SimpleTableColumn[] = [ + { id: "name", header: "Service" }, + { id: "prices", header: "Prices" }, + { id: "actions", srLabel: "Actions", width: "w-40" }, +]; + +type Editing = { id: string | null } | null; + +export function ServicesPanel() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const archivedId = useId(); + const [archived, setArchived] = useState(false); + const [editing, setEditing] = useState(null); + + const services = useQuery(trpc.proposals.services.queryOptions({ archived })); + + const setArchivedState = useMutation( + trpc.proposals.archiveService.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Service archived."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const restore = useMutation( + trpc.proposals.restoreService.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Service restored."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const rows = services.data ?? []; + + return ( + + + Services + + One catalog for every issuer. Each service keeps the text a proposal + starts from, and a price per issuer. + + + + + + +
+ + +
+ {rows.length === 0 ? ( + + {archived ? "No archived service." : "No service yet."} + + ) : ( + + {rows.map((service) => ( + + + {service.name} + + + {service.prices.length === 0 + ? "No price" + : service.prices + .map( + (price) => + `${price.issuerName} ${formatEur(price.amount)}`, + ) + .join(" · ")} + + +
+ + {service.archived ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ )} +
+ {editing ? ( + setEditing(null)} + /> + ) : null} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx b/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx index cdce91b4f..f7107a2dc 100644 --- a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx +++ b/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx @@ -2,9 +2,11 @@ import { Button } from "@crm/ui/components/button"; import { cn } from "@crm/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useMemo } from "react"; +import { useTRPC } from "@/lib/trpc/client"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; type SettingsNavItem = { @@ -24,6 +26,10 @@ const ITEMS: SettingsNavItem[] = [ { title: "SSO", href: `${ROOT}/sso` }, ]; +const OWNER_ITEMS: SettingsNavItem[] = [ + { title: "Proposals", href: `${ROOT}/proposals` }, +]; + function isActive(href: string, root: string, pathname: string): boolean { return href === root ? pathname === href : pathname.startsWith(href); } @@ -105,11 +111,18 @@ export function SettingsSidebarFallback() { export function SettingsSidebar() { const pathname = usePathname(); const workspaceUrl = useWorkspaceUrl(); + const trpc = useTRPC(); + const access = useQuery(trpc.proposals.access.queryOptions()); + const canManageProposals = access.data?.canManage === true; const root = workspaceUrl(ROOT); const items = useMemo( - () => ITEMS.map((item) => ({ ...item, href: workspaceUrl(item.href) })), - [workspaceUrl], + () => + [...ITEMS, ...(canManageProposals ? OWNER_ITEMS : [])].map((item) => ({ + ...item, + href: workspaceUrl(item.href), + })), + [workspaceUrl, canManageProposals], ); return ( diff --git a/apps/app/components/crm/proposals/company-proposal-profile.tsx b/apps/app/components/crm/proposals/company-proposal-profile.tsx new file mode 100644 index 000000000..36984858d --- /dev/null +++ b/apps/app/components/crm/proposals/company-proposal-profile.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@crm/ui/components/alert-dialog"; +import { Button } from "@crm/ui/components/button"; +import { EntityLogo } from "@crm/ui/components/entity-logo"; +import { Field, FieldDescription, FieldLabel } from "@crm/ui/components/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useId, useRef, useState } from "react"; +import { toast } from "sonner"; +import { DetailSheetSection } from "@/components/detail-sheet"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import { PROPOSAL_UI } from "./proposal-config"; +import { imageFileToPng } from "./proposal-files"; + +const NONE = "none"; + +export function CompanyProposalProfile({ companyId }: { companyId: string }) { + const trpc = useTRPC(); + const access = useQuery(trpc.proposals.access.queryOptions()); + + if (!access.data?.canManage) return null; + + return ( + + ); +} + +export function IssuerSelect({ + companyId, + id, +}: { + companyId: string; + id?: string; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const [pending, setPending] = useState(null); + + const profile = useQuery( + trpc.proposals.companyProfile.queryOptions({ companyId }), + ); + const issuers = useQuery(trpc.proposals.issuers.queryOptions()); + + const setIssuer = useMutation( + trpc.proposals.setCompanyIssuer.mutationOptions({ + onSuccess: async (next) => { + await cache.proposals(); + toast.success( + next.issuerName + ? `Proposals for this client now go out as ${next.issuerName}.` + : "This client has no issuer now.", + ); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (!profile.data) return null; + + const current = profile.data.issuerId ?? NONE; + const choose = (value: string) => { + if (value === current) return; + if (profile.data.proposals > 0) { + setPending(value); + return; + } + setIssuer.mutate({ companyId, issuerId: value === NONE ? null : value }); + }; + + const active = (issuers.data ?? []).filter( + (issuer) => issuer.active || issuer.id === profile.data.issuerId, + ); + + return ( + <> + + (open ? null : setPending(null))} + > + + + + Change the issuer of this client? + + + This client has {profile.data.proposals}{" "} + {profile.data.proposals === 1 ? "proposal" : "proposals"}. They + keep the issuer they were made with. New proposals use the new + issuer. + + + + Cancel + { + if (pending === null) return; + setIssuer.mutate({ + companyId, + issuerId: pending === NONE ? null : pending, + }); + setPending(null); + }} + > + Change issuer + + + + + + ); +} + +function ProposalProfileSection({ + companyId, + maxLogoBytes, +}: { + companyId: string; + maxLogoBytes: number; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const issuerId = useId(); + const fileInput = useRef(null); + const profile = useQuery( + trpc.proposals.companyProfile.queryOptions({ companyId }), + ); + + const setLogo = useMutation( + trpc.proposals.setClientLogo.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Logo saved for proposals."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const removeLogo = useMutation( + trpc.proposals.removeClientLogo.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Logo removed."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const pick = async (file: File | undefined) => { + if (!file) return; + try { + const contentBase64 = await imageFileToPng(file); + if ((contentBase64.length * 3) / 4 > maxLogoBytes) { + toast.error("The logo is too large. Use a smaller image."); + return; + } + setLogo.mutate({ companyId, contentBase64 }); + } catch (error) { + toast.error((error as Error).message); + } + }; + + const logo = profile.data?.logo ?? null; + + return ( + + + Issuer + + + Every proposal for this client goes out in the name of this company. + + + + Client logo + {logo ? ( + + ) : ( + + No logo. Templates with a client logo print an empty space. + + )} + { + void pick(event.target.files?.[0]); + event.target.value = ""; + }} + /> +
+ + {logo ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/components/crm/proposals/content-editor.tsx b/apps/app/components/crm/proposals/content-editor.tsx new file mode 100644 index 000000000..e1c21b254 --- /dev/null +++ b/apps/app/components/crm/proposals/content-editor.tsx @@ -0,0 +1,389 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import Close from "@carbon/icons-react/es/Close"; +import { Button } from "@crm/ui/components/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@crm/ui/components/field"; +import { Icon } from "@crm/ui/components/icon"; +import { Input } from "@crm/ui/components/input"; +import { Textarea } from "@crm/ui/components/textarea"; +import { useId, useState } from "react"; +import { PROPOSAL_UI } from "./proposal-config"; +import { type ProposalContent, toLines } from "./proposal-types"; + +function TextField({ + label, + description, + value, + onChange, + rows = 3, +}: { + label: string; + description?: string; + value: string; + onChange: (value: string) => void; + rows?: number; +}) { + const id = useId(); + return ( + + {label} +