Files
convite-rael/README.md

668 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Convites Digitais
Convites digitais de aniversário com confirmação de presença (RSVP), área do organizador e PDFs personalizados.
- **Convidados** acessam o convite pelo link `/r/<token>` e confirmam a presença de cada pessoa. Ao colar o link no WhatsApp aparece um card com o tema, o nome da festa e a data.
- **Organizador** acessa `/admin` com senha e:
- cadastra eventos e convites e escolhe o tema visual de cada evento;
- compartilha os links (copiar link, copiar mensagem / WhatsApp);
- acompanha as respostas e exporta a lista de convidados em CSV.
- **PDFs dos convites:** o organizador envia um PDF modelo por evento e o sistema gera um PDF por convite, trocando o link do modelo pelo link `/r/<token>` de cada família. A geração pode ser individual ou em lote, com progresso.
**Stack:** Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS 4 · PostgreSQL 17 · Drizzle ORM · Python 3.13 + FastAPI + PyMuPDF (serviço de PDFs) · Docker Compose
## Documentação
| Documento | Para quem | Conteúdo |
| --- | --- | --- |
| **README.md** (este) | Quem desenvolve e mantém o código | Arquitetura, código, banco, desenvolvimento local, como validar mudanças e manutenção do serviço Python |
| [instrucoes.md](instrucoes.md) | Administrador do sistema (quem faz o deploy) | Deploy, variáveis de ambiente, operação, backup, solução de problemas e histórico de versões |
| [MANUAL-USUARIO.md](MANUAL-USUARIO.md) | Organizador da festa (sem conhecimento técnico) | Criar eventos e convites, enviar, acompanhar respostas, CSV, PDFs, experiência do convidado e perguntas frequentes |
Cada documento fala com um público. Para **deploy, variáveis de ambiente, backup ou erros no servidor**, vá direto ao [instrucoes.md](instrucoes.md). Para **usar o sistema**, vá ao [MANUAL-USUARIO.md](MANUAL-USUARIO.md).
Os problemas mais comuns aparecem nos dois arquivos, de propósito, cada um para o seu público:
- **instrucoes.md (seção 11):** causa técnica e solução no servidor;
- **MANUAL-USUARIO.md (seção 5):** o que o organizador faz e quando chamar o administrador.
Ao mudar um deles, confira o outro.
---
## Arquitetura
Três containers no Docker Compose:
```
Navegador ──► Cloudflare (HTTPS) ──► app (Next.js :3000) ──► db (PostgreSQL) volume pgdata
│
│ HTTP interno (http://pdf-service:8000)
▼
pdf-service (Python)
│
volume app-storage ◄────────┴── /storage nos dois containers (PDFs)
```
| Serviço | Origem | Papel |
| --- | --- | --- |
| `app` | `Dockerfile` (Next standalone) | Páginas, área do organizador, RSVP, rotas de download. Aplica as migrations ao subir |
| `db` | `postgres:17-alpine` | Banco de dados. Sem porta publicada |
| `pdf-service` | `pdf-service/Dockerfile` | Personaliza os PDFs. Sem porta publicada: só o `app` o acessa |
Os dois containers que gravam arquivos (`app` e `pdf-service`) rodam com o **mesmo uid/gid 1001**, para compartilhar o volume `app-storage` sem erro de permissão.
---
## Estrutura do código
```
src/
app/
page.tsx Página inicial pública (neutra)
layout.tsx Layout raiz; metadataBase vem do APP_URL
r/[token]/ Convite: página, loading, not-found, opengraph-image (card do WhatsApp) e Server Action do RSVP
admin/login/ Login do organizador
admin/(painel)/ Área do organizador (layout protegido por sessão)
page.tsx Lista de eventos
eventos/novo/ Criar evento
eventos/[id]/ Página do evento, editar, novo convite
eventos/[id]/exportar/ Rota GET do CSV
eventos/[id]/pdf/ Rotas GET de status dos PDFs e download do modelo
eventos/[id]/convites/[conviteId]/pdf/ Rota GET de download do PDF do convite
convites/[id]/editar/ Editar convite
admin/actions.ts Server Actions de eventos e convites
admin/auth-actions.ts Server Actions de login, sair e sair de todos
admin/pdf-actions.ts Server Actions de PDF (upload do modelo, geração individual e em lote)
components/
invitation/ Componentes do convite (hero, detalhes, lista, RSVP, sucesso, rodapé)
admin/ Formulários e componentes do organizador (EventPdf.tsx: tela dos PDFs;
WhatsAppMessageField.tsx: campo e prévia da mensagem do WhatsApp)
ui/ Button, Card e Input (tone "theme" = cores do tema; "neutral" = admin)
db/
schema.ts Tabelas (ver "Banco de dados")
index.ts Pool de conexão com o Postgres
seed.ts Dados de exemplo (convites abc123 e xyz789)
lib/
auth.ts Sessão do organizador (token aleatório, hash guardado no banco)
login-rate-limit.ts Limite de tentativas de login por IP (em memória)
themes.ts Paletas, selos e ícones dos 15 temas
rsvp.ts Regras do prazo de confirmação
app-url.ts Endereço público do app (APP_URL)
invitation.ts Consultas do convite público
organizer.ts Consultas da área do organizador e totais de presença
slugify.ts Nomes de arquivo para download
whatsapp-message.ts Mensagem do botão WhatsApp: padrão, variáveis, limites e montagem do texto/link
pdf/ storage.ts (arquivos), service.ts (cliente do pdf-service), jobs.ts (fila), state.ts (status), types.ts
types/invitation.ts Tipos compartilhados do convite
instrumentation.ts Na subida do app, encerra gerações de PDF interrompidas
drizzle/ Migrations SQL geradas pelo drizzle-kit
scripts/migrate.ts Aplica as migrations (empacotado com esbuild e executado no container)
pdf-service/ Serviço Python de PDFs (FastAPI + PyMuPDF), com Dockerfile próprio
Dockerfile Imagem de produção do app (Next standalone)
compose.yml app + db + pdf-service
```
---
## Banco de dados
Definido em `src/db/schema.ts`. As migrations ficam em `drizzle/` e são aplicadas automaticamente quando o container `app` sobe.
### `events`: festas
| Coluna | Tipo | Significado |
| --- | --- | --- |
| `id` | uuid | Chave |
| `name`, `venue`, `address` | text | Nome da festa, local e endereço |
| `date` / `time` | date / time | Data e horário |
| `rsvp_deadline` | date, opcional | Último dia para responder (ver [Prazo de confirmação](#prazo-de-confirmação)) |
| `theme` | text | Id do tema em `src/lib/themes.ts` (padrão `safari`, só para eventos antigos) |
| `badge_text` / `footer_text` | text, opcionais | Textos do selo (até 40) e do rodapé (até 120). Vazios usam o padrão do tema |
| `whatsapp_message` | text, opcional | Modelo da mensagem do botão WhatsApp, com variáveis (`{nome}`, `{link}`...). Vazio usa a mensagem padrão. Ver [Mensagem do WhatsApp](#mensagem-do-whatsapp) |
| `pdf_template_version` | integer | Versão do PDF modelo. `0` = sem modelo; sobe 1 a cada upload |
| `pdf_template_name` / `pdf_template_updated_at` | text / timestamptz | Nome original e data do modelo atual (só para exibição) |
| `created_at` | timestamptz | Criação |
### `invitations`: um convite (link) por família
| Coluna | Tipo | Significado |
| --- | --- | --- |
| `id` | uuid | Chave |
| `event_id` | uuid | Evento (apagado em cascata com ele) |
| `token` | text, único | Código do link `/r/<token>`: 6 bytes aleatórios em base64url (8 caracteres) |
| `display_name` | text | Nome exibido ("Família Silva") |
| `responded_at` | timestamptz | Último envio de resposta pela família |
| `blocked` | boolean, padrão `false` | Bloqueio individual: encerra as respostas deste convite (ver [Prazo de confirmação](#prazo-de-confirmação)) |
| `pdf_status` | enum | `not_generated`, `generating`, `generated` ou `error` |
| `pdf_template_version` | integer | Versão do modelo usada na última geração **bem-sucedida** |
| `pdf_generated_at` | timestamptz | Data da última geração bem-sucedida (não nulo = existe arquivo para baixar) |
| `pdf_error` | text | Última mensagem de erro de geração |
| `created_at` | timestamptz | Criação |
### `guests`: pessoas de cada convite
| Coluna | Tipo | Significado |
| --- | --- | --- |
| `id` | uuid | Chave (mantida ao editar o convite, para preservar a resposta) |
| `invitation_id` | uuid | Convite (apagado em cascata) |
| `name` | text | Nome |
| `type` | enum `guest_type` | `adult`, `teen` ou `child` |
| `status` | enum `rsvp_status` | `pending`, `confirmed` ou `declined` (valor **gravado**; ver prazo) |
| `sort_order` | integer | Ordem na lista |
| `responded_at` | timestamptz | Quando a pessoa foi respondida |
### `organizer_sessions`: sessões do `/admin`
| Coluna | Significado |
| --- | --- |
| `id` | **Hash SHA-256** do token do cookie (o token em si nunca é gravado) |
| `user_agent` | Navegador que fez o login |
| `created_at` / `expires_at` | Criação e validade (7 dias) |
### `pdf_jobs`: lotes de geração de PDF
| Coluna | Significado |
| --- | --- |
| `event_id` | Evento (apagado em cascata) |
| `status` | `running`, `finished` ou `interrupted`. Índice único parcial: **um `running` por evento** |
| `invitation_ids` | jsonb com os convites do lote, na ordem de processamento |
| `total` / `processed` / `failed` | Contadores do progresso |
| `created_at` / `finished_at` | Início e fim |
Para mudar o banco: altere `schema.ts`, rode `npm run db:generate` e confira o SQL gerado em `drizzle/`.
---
## Como o código funciona
### Autenticação do organizador
- **Senha única:** `ORGANIZER_PASSWORD`, comparada via HMAC com `SESSION_SECRET` em `src/lib/auth.ts` (o tempo da comparação não depende da senha).
- **Sessão:** o login cria um token aleatório de 32 bytes no cookie `organizer_session` (httpOnly, sameSite=lax, `secure` conforme `COOKIE_SECURE`). Só o hash vai para `organizer_sessions`.
- **Sair** apaga a linha da sessão atual; **Sair de todos** apaga todas.
- Sessões vencidas são limpas a cada login.
- **Proteção:** `requireOrganizer()` deve ser chamado em **toda** página, Server Action e rota da área do organizador. O layout `(painel)` já chama, mas rotas (`route.ts`) e Server Actions não passam pelo layout e precisam chamar por conta própria. A rota de status dos PDFs usa `isOrganizer()` para responder 401 em JSON em vez de redirecionar.
- **Limite de tentativas** (`src/lib/login-rate-limit.ts`): 5 erros em 15 min bloqueiam o IP por 15 min.
- Os contadores ficam em memória e zeram quando o app reinicia.
- O IP vem de `cf-connecting-ip`, depois `x-forwarded-for` e `x-real-ip`. Esses cabeçalhos só são confiáveis se o servidor aceitar apenas tráfego do Cloudflare.
### Convite público e RSVP
- `src/app/r/[token]/page.tsx` sempre renderiza na hora (`connection()`): o convite muda com respostas e edições.
- `InvitationView` (client) guarda as marcações; o botão marcado fica desativado, então não há "desmarcar".
- `submitRsvp` (`src/app/r/[token]/actions.ts`) é chamável por POST direto, por isso revalida tudo no servidor:
- token válido;
- prazo aberto;
- todos os `guestId` pertencem ao convite.
Grava só as pessoas enviadas (`confirmed`/`declined`) e atualiza `responded_at`.
- `opengraph-image.tsx` gera a imagem 1200×630 do card. As URLs absolutas do card usam `metadataBase`, que vem do `APP_URL` (`src/lib/app-url.ts`).
- **Copiar link**, **Copiar mensagem** e **WhatsApp** usam `window.location.origin`, ou seja, o endereço pelo qual o organizador está navegando, e não o `APP_URL`.
### Mensagem do WhatsApp
`src/lib/whatsapp-message.ts` concentra as regras; é um módulo puro, usado no servidor e no navegador.
- **Modelo por evento:** a coluna `events.whatsapp_message` guarda o modelo. Vazia, vale `DEFAULT_WHATSAPP_MESSAGE`. Não há mensagem por convite, por decisão de produto: para um caso especial, o organizador edita o texto no próprio WhatsApp.
- **Montagem** (`renderWhatsAppMessage`):
- troca `{nome}`, `{evento}`, `{data}`, `{hora}`, `{local}`, `{endereco}`, `{icone}` e `{link}`;
- chaves desconhecidas ficam como estão;
- sem `{link}` no modelo, o link é acrescentado numa linha no final.
- **Envio:** `ShareInvitation` monta o texto com os dados do convite. **Copiar mensagem** copia esse texto; **WhatsApp** abre `whatsappShareUrl(text)` (`https://wa.me/?text=<texto codificado>`). A formatação do WhatsApp (`*negrito*`, `_itálico_`...) passa como texto puro e o próprio app aplica.
- **Limites** (só para a mensagem personalizada):
| Constante | Valor | O que limita |
| --- | --- | --- |
| `WHATSAPP_MESSAGE_MAX` | 950 | Caracteres do modelo e do texto final, com as variáveis trocadas |
| `WHATSAPP_URL_MAX` | 2300 | Tamanho do link codificado do `wa.me`. Acentos ocupam 6 caracteres e emojis até 12 |
O WhatsApp não publica um limite oficial: os valores são margens de segurança.
- **Conferência no servidor** (`src/app/admin/actions.ts`, funções `measureWhatsAppMessage` e `whatsappLimitError`):
- ao **criar** o evento, com os dados do evento e `{nome}` vazio;
- ao **editar** o evento, com o nome de cada convite existente;
- ao **criar ou editar um convite**, com o nome informado.
Nas três, o link é estimado com `getAppUrl().origin` mais um código de 8 caracteres (`sampleInviteLink`). Como o botão usa a origem do navegador, o tamanho real pode variar alguns caracteres.
- **Formulário** (`WhatsAppMessageField.tsx`):
- botões que inserem as variáveis no cursor;
- três contadores: modelo, texto final e link, calculados com dados de exemplo;
- prévia em balão que imita a formatação do WhatsApp.
A expressão regular da prévia usa lookbehind (ES2018, Safari ≥ 16.4) e é criada dentro de `try`. Sem suporte, a prévia sai sem formatação, em vez de quebrar o formulário.
### Prazo de confirmação
`src/lib/rsvp.ts`:
- `isRsvpClosed(deadline)`: o prazo vale até o fim do dia informado, no fuso `America/Sao_Paulo`.
- `effectiveStatus(status, closed)`: depois do prazo, `pending` é **exibido** como `declined`.
O banco não é alterado. Todas as telas e o CSV usam `effectiveStatus`, então adiar ou apagar o prazo reverte a exibição.
**Bloqueio individual:** `invitations.blocked` tem o mesmo efeito do prazo vencido, mas só naquele convite (`setInvitationBlocked` em `src/app/admin/actions.ts`). Se só o bloqueio encerrou o convite, a mensagem do convidado sai sem a data; com o prazo também vencido, ela mostra a data.
### Temas
`src/lib/themes.ts` define os 15 temas (paleta, selo, ícones). Os componentes do convite usam classes `amber-*` do Tailwind, e `themeStyle()` redefine as variáveis `--color-amber-*` no contêiner. Assim, trocar o tema troca a paleta inteira sem mexer nos componentes. Para criar um tema, acrescente um objeto ao array `themes`.
### Exportação CSV
`GET /admin/eventos/<id>/exportar?status=confirmed|declined|pending` (`exportar/route.ts`):
- separador `;` com BOM UTF-8, para o Excel em português abrir certo;
- valores que começam com `= + - @` ganham um apóstrofo, para não virarem fórmula.
### PDFs dos convites
```
Navegador ──► app: valida login, evento e convite
│ HTTP interno: { event_id, invitation_id, token, template_version }
▼
pdf-service: abre /storage/events/<evento>/template/modelo-v<versão>.pdf
troca os links que contêm INVITE_LINK_PATTERN por <INVITE_LINK_BASE_URL ou APP_URL>/r/<token>
grava /storage/events/<evento>/generated/<convite>.pdf
```
- **Armazenamento** (`src/lib/pdf/storage.ts` e `pdf-service/app/main.py`, com a mesma estrutura):
```
/storage/events/<eventId>/template/modelo-v<versão>.pdf PDF modelo (só a versão atual fica no disco)
/storage/events/<eventId>/generated/<invitationId>.pdf PDF personalizado de cada convite
```
Os caminhos são montados só a partir de ids validados. Nada vindo do navegador vira caminho.
- **Upload** (`uploadPdfTemplate`):
1. Confere extensão, tamanho (10 MB, `PDF_TEMPLATE_MAX_MB` em `src/lib/pdf/types.ts`) e a assinatura `%PDF-`.
2. Grava `modelo-v<n+1>.pdf` e chama `/templates/inspect`; sem link de convite, apaga o arquivo e recusa.
3. Atualiza a versão com condição na versão anterior, para que dois uploads simultâneos não se sobrescrevam.
4. Apaga os modelos antigos.
O limite de corpo das Server Actions é `11mb` em `next.config.ts` e deve acompanhar `PDF_TEMPLATE_MAX_MB`.
- **Versão:** um PDF com `pdf_template_version` diferente da do evento é exibido como `outdated` ("Modelo atualizado — gerar novamente"), calculado em `pdfViewStatus` (`src/lib/pdf/state.ts`).
- **Geração individual** (`generateInvitationPdfAction`):
1. `claimInvitation` marca o convite como `generating` só se ele for do evento e ainda não estiver em geração. Isso impede duplicidade.
2. `generateForInvitation` chama o serviço e grava o resultado. Em caso de erro, mantém `pdf_generated_at` e o arquivo anterior.
- **Lote** (`startPdfBatch` → `createPdfJob` → `enqueuePdfJob` → `runPdfJob`, em `src/lib/pdf/jobs.ts`):
- cria a linha em `pdf_jobs`, marca os convites e processa **um por vez** em segundo plano, no próprio processo do Next;
- uma falha não interrompe o lote;
- para trocar por Redis/BullMQ, substitua `enqueuePdfJob()` por um produtor da fila e chame `runPdfJob()` no worker.
- **Reinício:** `src/instrumentation.ts` chama `recoverInterruptedPdfWork()` na subida. Lotes `running` viram `interrupted` e convites `generating` viram `error`.
- **Tela** (`src/components/admin/EventPdf.tsx`): `EventPdfProvider` guarda o estado e consulta `GET .../pdf/status` a cada 1,5 s enquanto há geração. A página recria o provider (`key`) quando a versão do modelo muda.
- **Exclusão:** excluir convite ou evento apaga os arquivos correspondentes (`src/app/admin/actions.ts`).
**Endpoints:**
| Operação | Tipo | Endereço / função |
| --- | --- | --- |
| Upload do PDF modelo | Server Action | `uploadPdfTemplate(eventId, prev, formData)` |
| Gerar PDF de um convite | Server Action | `generateInvitationPdfAction(eventId, invitationId)` |
| Gerar PDFs em lote | Server Action | `startPdfBatch(eventId, "all" \| "pending")` |
| Status da geração | GET | `/admin/eventos/<id>/pdf/status` (JSON; 401 sem sessão) |
| Download do modelo | GET | `/admin/eventos/<id>/pdf/modelo` |
| Download do PDF do convite | GET | `/admin/eventos/<id>/convites/<conviteId>/pdf` (confere se o convite é do evento) |
| pdf-service | GET / POST | `/health`, `/templates/inspect`, `/invitations/generate` (ver [Manutenção do pdf-service](#manutenção-do-pdf-service-código-python)) |
---
## Variáveis de ambiente
A lista completa, com exemplos, está na **seção 4 do [instrucoes.md](instrucoes.md#4-configurar-as-variáveis-de-ambiente)**. Para desenvolvimento, as que importam fora do Docker são:
| Variável | Uso no desenvolvimento |
| --- | --- |
| `DATABASE_URL` | Conexão com o Postgres local (`npm run dev`, `db:migrate`, `db:seed`) |
| `PDF_SERVICE_URL` | `http://localhost:8000` quando o pdf-service roda na sua máquina (padrão `http://pdf-service:8000`) |
| `STORAGE_DIR` | Pasta dos PDFs; padrão `./storage` (está no `.gitignore`). O pdf-service local precisa apontar para a **mesma** pasta |
| `COOKIE_SECURE` | `false` para `http://localhost` |
---
## Desenvolvimento local
Requer Node.js 20.9+ e um Postgres acessível. Pode ser só o serviço `db` do compose, liberando a porta `127.0.0.1:5432` no `compose.yml`.
```bash
npm install
cp .env.example .env # ajuste DATABASE_URL
npm run db:migrate # cria as tabelas
npm run db:seed # opcional: convites de exemplo
npm run dev
```
Acesse `http://localhost:3000`. Para abrir pela rede local (ex.: celular), o IP precisa estar em `allowedDevOrigins` no `next.config.ts`; sem isso, a página fica em branco.
### pdf-service local
Para gerar PDFs no `npm run dev`, rode o serviço Python apontando para a **mesma pasta** de armazenamento do app (Python 3.11+):
```bash
cd pdf-service
python -m venv .venv
.venv/bin/pip install -r requirements.txt # Windows: .venv\Scripts\pip
STORAGE_DIR=../storage APP_URL=http://localhost:3000 .venv/bin/uvicorn app.main:app --port 8000 --reload
```
No `.env` do app, descomente `PDF_SERVICE_URL=http://localhost:8000` e `STORAGE_DIR=./storage`.
Outra opção é subir só ele pelo compose, liberando a porta temporariamente (`ports: ["127.0.0.1:8000:8000"]`) e montando `./storage` em `/storage`.
### Scripts
| Script | O que faz |
| --- | --- |
| `npm run dev` | Servidor de desenvolvimento |
| `npm run build` | Build de produção (Next standalone) |
| `npm run lint` | ESLint |
| `npx tsc --noEmit` | Checagem de tipos |
| `npm run db:generate` | Gera uma nova migration após alterar `src/db/schema.ts` |
| `npm run db:migrate` | Aplica as migrations no banco do `DATABASE_URL` |
| `npm run db:seed` | Cria os convites de exemplo (`abc123`, `xyz789`) |
| `npm run db:studio` | Abre o Drizzle Studio para ver e editar os dados |
| `npm run build:migrate` | Empacota `scripts/migrate.ts` em `dist/migrate.js` (usado no Dockerfile) |
O seed não faz parte da imagem Docker. Para rodá-lo contra o banco do compose, libere a porta do Postgres em `127.0.0.1:5432` no `compose.yml` e rode `npm run db:seed` na máquina com o código.
### Next.js 16
Esta versão tem mudanças em relação às anteriores. Antes de usar uma API do Next, consulte a documentação instalada em `node_modules/next/dist/docs/` (ver `AGENTS.md`).
---
## Como validar uma mudança
O projeto **não tem testes automatizados**. Antes de publicar, siga esta ordem:
1. **Tipos e estilo:** `npx tsc --noEmit` e `npm run lint` (os dois devem terminar sem erros).
2. **Build:** `npm run build`. Se mexeu em `pdf-service/`, rode também `docker compose build pdf-service`.
3. **Migrations:** se alterou o `schema.ts`, confira o SQL novo em `drizzle/` e aplique num banco de teste (`npm run db:migrate`).
4. **Teste manual do que mudou.** Roteiro mínimo:
- login e logout no `/admin`;
- criar evento e convite, abrir o link, responder e ver a resposta na página do evento;
- se o evento tiver prazo: conferir a exibição antes e depois dele;
- exportar um CSV;
- WhatsApp: personalizar a mensagem do evento, conferir a prévia e os contadores, clicar em **WhatsApp** num convite e conferir texto, formatação e link;
- PDFs: enviar o modelo, gerar um convite, baixar e clicar no link, gerar em lote.
5. **Documentação:**
- comportamento visível para o organizador ou o convidado → atualize o [MANUAL-USUARIO.md](MANUAL-USUARIO.md), sem termos técnicos;
- variável nova, deploy ou operação do servidor → atualize o [instrucoes.md](instrucoes.md);
- código, banco ou arquitetura → atualize este README;
- mudança publicada → acrescente uma versão na Parte 2 (Histórico de alterações) do instrucoes.md.
---
## Manutenção do `pdf-service` (código Python)
Para quem precisa **alterar o serviço Python**: mudar a regra de troca do link, atualizar bibliotecas, depurar um erro, etc.
### Arquivos
```
pdf-service/
Dockerfile Imagem Python 3.13-slim; roda como uid/gid 1001 (o mesmo do app)
requirements.txt Dependências: fastapi, uvicorn, pymupdf
.dockerignore
app/
__init__.py
main.py Todo o serviço: endpoints, caminhos e a troca do link
```
Do lado do Next, os arquivos que "conversam" com o serviço são:
| Arquivo | Papel |
| --- | --- |
| `src/lib/pdf/service.ts` | Cliente HTTP: monta as chamadas a `/templates/inspect` e `/invitations/generate`, com timeout de 60 s (`GENERATE_TIMEOUT_MS`) |
| `src/lib/pdf/storage.ts` | Mesma estrutura de pastas usada pelo Python (o app grava o modelo; o Python grava os gerados) |
| `src/lib/pdf/jobs.ts` | Chama a geração para um convite e para os lotes; grava status e erro no banco |
| `src/app/admin/pdf-actions.ts` | Upload do modelo: salva o arquivo e chama `/templates/inspect` para validar |
> ⚠️ **Regra de ouro:** o contrato entre os dois lados (endpoints, campos JSON e estrutura de pastas) existe nos **dois** códigos. Se mudar de um lado, mude do outro e publique os dois juntos.
### Como o `main.py` funciona
**Configuração (lida na subida do serviço):**
| Variável | Padrão | Uso no código |
| --- | --- | --- |
| `STORAGE_DIR` | `/storage` | Raiz dos arquivos (`STORAGE_DIR`) |
| `INVITE_LINK_BASE_URL` | vazio | Base do link novo (`LINK_BASE_URL`). Vazio → usa `APP_URL` |
| `APP_URL` | vazio | Usado como base do link quando `INVITE_LINK_BASE_URL` está vazio. Os dois vazios → a geração responde erro 500 |
| `INVITE_LINK_PATTERN` | `com.br/r/` | Trecho que identifica o link a trocar (`LINK_PATTERN`) |
Alterar uma variável **não exige rebuild**: edite o `.env` e rode `docker compose up -d pdf-service`.
**Funções principais:**
| Função | O que faz |
| --- | --- |
| `_template_path(event_id, version)` | Monta `/storage/events/<evento>/template/modelo-v<versão>.pdf` |
| `_generated_path(event_id, invitation_id)` | Monta `/storage/events/<evento>/generated/<convite>.pdf` |
| `_inside_storage(path)` | Garante que o caminho final está dentro de `STORAGE_DIR` (proteção contra path traversal) |
| `_is_invite_link(link)` | **Regra que decide se um link é o do convite**: `LINK_PATTERN in uri` |
| `_open_template(path)` | Abre o modelo; 404 se não existe, 422 se não é um PDF legível |
| `inspect_template` | `POST /templates/inspect`: conta quantos links de convite o modelo tem |
| `generate_invitation` | `POST /invitations/generate`: troca os links e grava o PDF do convite |
**Passo a passo da geração (`generate_invitation`):**
1. Valida os campos recebidos (UUIDs, token só com `A-Z a-z 0-9 _ -`, versão ≥ 1). Se algum for inválido, o FastAPI responde 422 automaticamente.
2. Monta o link novo: `<INVITE_LINK_BASE_URL>/r/<token>` (ou `<APP_URL>/r/<token>`, se a primeira estiver vazia).
3. Abre o modelo da versão informada.
4. Percorre todas as páginas e, em cada uma, todos os links (`page.get_links()`). Para cada link aprovado por `_is_invite_link`, troca `link["uri"]` e chama `page.update_link(link)`.
5. Se nenhum link foi trocado, responde 422 ("Link do convite não encontrado no PDF modelo.").
6. Salva num arquivo temporário (`*.tmp`) na pasta `generated/` e só então o renomeia para o nome final (`os.replace`). Se algo falhar antes disso, o PDF anterior continua intacto e o temporário é apagado.
**Contrato HTTP (o que o Next envia e espera):**
| Endpoint | Corpo enviado | Resposta de sucesso |
| --- | --- | --- |
| `GET /health` | — | `{"status": "ok", "storage": true, "link_base_url": true}` |
| `POST /templates/inspect` | `{"event_id": "<uuid>", "template_version": 3}` | `{"links": 1}` |
| `POST /invitations/generate` | `{"event_id": "<uuid>", "invitation_id": "<uuid>", "token": "Ab3xY9kL", "template_version": 3}` | `{"links": 1}` |
**Erros:** o serviço responde `{"detail": "<mensagem>"}`. Quando `detail` é texto, o Next mostra essa mensagem na tela (no card do convite ou no upload). Mantenha as mensagens **em português e úteis para o organizador**.
| Código | Quando |
| --- | --- |
| 400 | Caminho fora de `STORAGE_DIR` |
| 404 | Modelo não encontrado no disco |
| 422 | PDF ilegível, link não encontrado ou campos inválidos |
| 500 | `APP_URL` e `INVITE_LINK_BASE_URL` ausentes, ou erro inesperado (detalhes completos no log) |
### Ajustes comuns
**a) Mudar o trecho que identifica o link** (ex.: só `n1b3.com.br/r/`)
Não precisa mexer no código. Altere `INVITE_LINK_PATTERN` no `.env` e rode `docker compose up -d pdf-service`.
**b) Mudar a regra de identificação** (ex.: expressão regular, domínio exato, ignorar maiúsculas)
Altere só a função `_is_invite_link` em `pdf-service/app/main.py`. Exemplo com expressão regular:
```python
import re
LINK_RE = re.compile(r"^https://(convite\.)?n1b3\.com\.br/r/[A-Za-z0-9_-]+$")
def _is_invite_link(link: dict) -> bool:
return bool(LINK_RE.match(link.get("uri") or ""))
```
Ela é usada tanto na validação do upload (`inspect`) quanto na geração, então as duas continuam coerentes.
**c) Mudar o formato do link gravado** (ex.: incluir `?origem=pdf`)
Altere a linha `new_link = f"{LINK_BASE_URL}/r/{req.token}"` em `generate_invitation`. Depois, gere de novo os PDFs dos eventos afetados.
**d) Trocar também texto visível ou QR code**
O código atual troca **só links clicáveis**. Trocar texto impresso ou QR code é outra técnica:
- **Texto:** remover o texto antigo (`page.add_redact_annot` + `apply_redactions`) e escrever o novo (`page.insert_text`). Isso pode alterar fonte e posição.
- **QR code:** gerar a imagem do QR (biblioteca `qrcode`) e sobrepô-la com `page.insert_image`.
Nos dois casos, teste com o modelo real antes de publicar.
**e) Receber um campo novo do Next** (ex.: nome da família para escrever no PDF)
1. Python: adicione o campo em `GenerateRequest`, ex.: `display_name: str = Field(max_length=120)`.
2. Next: inclua o campo no `post("/invitations/generate", {...})` em `src/lib/pdf/service.ts` e passe o valor em `generateForInvitation` (`src/lib/pdf/jobs.ts`), lendo-o do banco.
3. Publique os dois serviços juntos (`docker compose up -d --build`).
> Nunca receba **caminhos de arquivo** do Next ou do navegador. Os caminhos devem continuar sendo montados no Python a partir de ids validados.
**f) Mudar a estrutura de pastas**
Altere **juntos** `_template_path` / `_generated_path` (Python) e `templatePath` / `generatedPdfPath` (`src/lib/pdf/storage.ts`). Arquivos já existentes no volume não são movidos sozinhos: mova-os ou gere tudo de novo, e envie os modelos novamente.
**g) Arquivo final maior ou menor**
Hoje o PDF é salvo com `doc.save(tmp_name)`, sem recompactar, para preservar o original ao máximo. Para reduzir o tamanho, dá para usar `doc.save(tmp_name, garbage=3, deflate=True)`. Compare visualmente o resultado antes de adotar.
**h) Desempenho e tempo limite**
| Ajuste | Onde |
| --- | --- |
| Processos do serviço (padrão 2) | `--workers` no `CMD` do `pdf-service/Dockerfile` |
| Tempo máximo por PDF (padrão 60 s) | `GENERATE_TIMEOUT_MS` em `src/lib/pdf/service.ts` |
| Intervalo de atualização da tela (padrão 1,5 s) | `POLL_INTERVAL_MS` em `src/components/admin/EventPdf.tsx` |
O lote processa **um convite por vez** de propósito, para não sobrecarregar o servidor.
**i) Atualizar as bibliotecas**
O `requirements.txt` usa faixas de versão. Para fixar as versões que estão funcionando hoje:
```bash
docker compose exec pdf-service pip freeze
```
Copie as linhas de `fastapi`, `uvicorn`, `pymupdf` (e `pydantic`, `starlette`, se quiser fixar tudo) para o `requirements.txt` com `==`, por exemplo `pymupdf==1.26.4`. Para atualizar:
1. Troque a versão no `requirements.txt`.
2. Rode `docker compose build --no-cache pdf-service`.
3. Teste ([Testar uma alteração](#testar-uma-alteração)).
4. Publique ([Publicar e desfazer](#publicar-e-desfazer)).
### Testar uma alteração
**Opção 1 — Python na sua máquina, sem Docker** (Python 3.11 ou mais novo)
```powershell
# Windows (PowerShell), dentro da pasta pdf-service
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
$env:STORAGE_DIR = "..\storage"; $env:INVITE_LINK_BASE_URL = "http://localhost:3000"
.venv\Scripts\uvicorn app.main:app --port 8000 --reload
```
```bash
# Linux/macOS
cd pdf-service
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
STORAGE_DIR=../storage INVITE_LINK_BASE_URL=http://localhost:3000 .venv/bin/uvicorn app.main:app --port 8000 --reload
```
O `--reload` reinicia o serviço a cada alteração no `main.py`.
Para testar sem o app, coloque um modelo na pasta esperada. Qualquer UUID serve como id de teste:
```
storage/events/11111111-1111-1111-1111-111111111111/template/modelo-v1.pdf
```
E chame os endpoints, em outro terminal:
```bash
curl http://localhost:8000/health
curl -X POST http://localhost:8000/templates/inspect -H "Content-Type: application/json" -d "{\"event_id\":\"11111111-1111-1111-1111-111111111111\",\"template_version\":1}"
curl -X POST http://localhost:8000/invitations/generate -H "Content-Type: application/json" -d "{\"event_id\":\"11111111-1111-1111-1111-111111111111\",\"invitation_id\":\"22222222-2222-2222-2222-222222222222\",\"token\":\"TESTE123\",\"template_version\":1}"
```
O PDF sai em `storage/events/1111…/generated/22222222-2222-2222-2222-222222222222.pdf`. Abra-o e confira:
- o link aponta para `http://localhost:3000/r/TESTE123`;
- o resto do PDF está igual ao modelo.
Para testar junto com o app no `npm run dev`, veja [pdf-service local](#pdf-service-local).
**Opção 2 — No servidor, com Docker**
```bash
docker compose up -d --build pdf-service
docker compose ps pdf-service # aguarde ficar "healthy"
docker compose logs -f pdf-service
```
Depois teste pela tela: envie o modelo (valida o `inspect`), gere o PDF de um convite e baixe-o.
O serviço não tem porta publicada. Para chamá-lo direto, use o app como ponte:
```bash
docker compose exec app wget -qO- http://pdf-service:8000/health
```
**Checklist antes de publicar uma alteração:**
- [ ] `GET /health` responde `"status": "ok"`.
- [ ] Upload de um modelo válido é aceito; um PDF sem o link é recusado com mensagem clara.
- [ ] O PDF gerado abre normalmente, o link leva a `<APP_URL>/r/<token>` (ou `<INVITE_LINK_BASE_URL>/r/<token>`, se definida) e o layout, as imagens, as fontes e os demais links continuam iguais.
- [ ] Um erro proposital (ex.: apagar o modelo do disco) aparece como "Erro ao gerar" no card, com a mensagem, e o PDF anterior continua disponível.
- [ ] A geração em lote termina e mostra o total correto.
- [ ] Se o contrato mudou, o app também foi alterado e publicado junto.
### Publicar e desfazer
**Publicar:**
| O que mudou | Comando |
| --- | --- |
| Só `pdf-service/` (código ou `requirements.txt`) | `docker compose up -d --build pdf-service` |
| Só variáveis `INVITE_LINK_*` no `.env` | `docker compose up -d pdf-service` |
| Contrato (Python **e** Next) | `docker compose up -d --build` |
- Publicar o `pdf-service` **não derruba** os convites nem o RSVP: só a geração de PDFs fica indisponível por alguns segundos.
- Evite publicar durante uma geração em lote. Se acontecer, os convites que falharem ficam com erro e basta clicar em **Gerar só pendentes e com erro**.
- Se a alteração muda o PDF gerado (regra, formato do link etc.), os PDFs antigos **não** são refeitos sozinhos. Use **Gerar PDFs de todos os convites** em cada evento.
**Desfazer:** antes de copiar a versão nova, guarde uma cópia da pasta atual:
```bash
cp -r pdf-service pdf-service.bak-$(date +%F)
```
Se der problema, volte a cópia e reconstrua:
```bash
rm -rf pdf-service && mv pdf-service.bak-AAAA-MM-DD pdf-service
docker compose up -d --build pdf-service
```
### Depuração
- **Logs:** cada geração registra `PDF gerado convite=... evento=... links=N`. Erros inesperados aparecem com o traceback completo:
```bash
docker compose logs --since 30m pdf-service
```
- **Mais detalhes:** troque `level=logging.INFO` por `level=logging.DEBUG` em `logging.basicConfig` e acrescente `log.debug(...)` onde precisar. Reconstrua o serviço depois.
- **Inspecionar os links de um PDF:** dentro do container, liste os links de um modelo:
```bash
docker compose exec pdf-service python -c "import fitz,sys; d=fitz.open(sys.argv[1]); [print(p.number+1, l.get('uri')) for p in d for l in p.get_links()]" /storage/events/<eventId>/template/modelo-v<versão>.pdf
```
Mostra a página e a URI de cada link. É útil para entender por que um modelo foi recusado.
- **Erro só no app** ("Serviço de PDF indisponível"): o app não conseguiu falar com o serviço. Veja:
- se o container está `healthy`;
- a variável `PDF_SERVICE_URL` (no compose deve ser `http://pdf-service:8000`);
- o log do app, que mostra o erro de conexão.
---
## Deploy
Deploy, publicação no Cloudflare, atualização de versão, backup e solução de problemas estão no **[instrucoes.md](instrucoes.md)** (Parte 1).