668 lines
36 KiB
Markdown
668 lines
36 KiB
Markdown
# 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).
|