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
/admincom 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 | 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 | 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. Para usar o sistema, vá ao 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) |
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 |
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) |
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 comSESSION_SECRETemsrc/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,secureconformeCOOKIE_SECURE). Só o hash vai paraorganizer_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 usaisOrganizer()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, depoisx-forwarded-forex-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.tsxsempre 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
guestIdpertencem ao convite.
Grava só as pessoas enviadas (
confirmed/declined) e atualizaresponded_at. -
opengraph-image.tsxgera a imagem 1200×630 do card. As URLs absolutas do card usammetadataBase, que vem doAPP_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 oAPP_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_messageguarda o modelo. Vazia, valeDEFAULT_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.
- troca
-
Envio:
ShareInvitationmonta o texto com os dados do convite. Copiar mensagem copia esse texto; WhatsApp abrewhatsappShareUrl(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_MAX950 Caracteres do modelo e do texto final, com as variáveis trocadas WHATSAPP_URL_MAX2300 Tamanho do link codificado do wa.me. Acentos ocupam 6 caracteres e emojis até 12O 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çõesmeasureWhatsAppMessageewhatsappLimitError):- 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().originmais um código de 8 caracteres (sampleInviteLink). Como o botão usa a origem do navegador, o tamanho real pode variar alguns caracteres. - ao criar o evento, com os dados do evento e
-
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 fusoAmerica/Sao_Paulo.effectiveStatus(status, closed): depois do prazo,pendingé exibido comodeclined.
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.tsepdf-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 conviteOs caminhos são montados só a partir de ids validados. Nada vindo do navegador vira caminho.
-
Upload (
uploadPdfTemplate):- Confere extensão, tamanho (10 MB,
PDF_TEMPLATE_MAX_MBemsrc/lib/pdf/types.ts) e a assinatura%PDF-. - Grava
modelo-v<n+1>.pdfe chama/templates/inspect; sem link de convite, apaga o arquivo e recusa. - Atualiza a versão com condição na versão anterior, para que dois uploads simultâneos não se sobrescrevam.
- Apaga os modelos antigos.
O limite de corpo das Server Actions é
11mbemnext.config.tse deve acompanharPDF_TEMPLATE_MAX_MB. - Confere extensão, tamanho (10 MB,
-
Versão: um PDF com
pdf_template_versiondiferente da do evento é exibido comooutdated("Modelo atualizado — gerar novamente"), calculado empdfViewStatus(src/lib/pdf/state.ts). -
Geração individual (
generateInvitationPdfAction):claimInvitationmarca o convite comogeneratingsó se ele for do evento e ainda não estiver em geração. Isso impede duplicidade.generateForInvitationchama o serviço e grava o resultado. Em caso de erro, mantémpdf_generated_ate o arquivo anterior.
-
Lote (
startPdfBatch→createPdfJob→enqueuePdfJob→runPdfJob, emsrc/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 chamerunPdfJob()no worker.
- cria a linha em
-
Reinício:
src/instrumentation.tschamarecoverInterruptedPdfWork()na subida. Lotesrunningviraminterruptede convitesgeneratingviramerror. -
Tela (
src/components/admin/EventPdf.tsx):EventPdfProviderguarda o estado e consultaGET .../pdf/statusa 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) |
Variáveis de ambiente
A lista completa, com exemplos, está na seção 4 do instrucoes.md. 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.
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+):
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:
- Tipos e estilo:
npx tsc --noEmitenpm run lint(os dois devem terminar sem erros). - Build:
npm run build. Se mexeu empdf-service/, rode tambémdocker compose build pdf-service. - Migrations: se alterou o
schema.ts, confira o SQL novo emdrizzle/e aplique num banco de teste (npm run db:migrate). - 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.
- login e logout no
- Documentação:
- comportamento visível para o organizador ou o convidado → atualize o MANUAL-USUARIO.md, sem termos técnicos;
- variável nova, deploy ou operação do servidor → atualize o 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):
- 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. - Monta o link novo:
<INVITE_LINK_BASE_URL>/r/<token>(ou<APP_URL>/r/<token>, se a primeira estiver vazia). - Abre o modelo da versão informada.
- Percorre todas as páginas e, em cada uma, todos os links (
page.get_links()). Para cada link aprovado por_is_invite_link, trocalink["uri"]e chamapage.update_link(link). - Se nenhum link foi trocado, responde 422 ("Link do convite não encontrado no PDF modelo.").
- Salva num arquivo temporário (
*.tmp) na pastagenerated/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:
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 compage.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)
- Python: adicione o campo em
GenerateRequest, ex.:display_name: str = Field(max_length=120). - Next: inclua o campo no
post("/invitations/generate", {...})emsrc/lib/pdf/service.tse passe o valor emgenerateForInvitation(src/lib/pdf/jobs.ts), lendo-o do banco. - 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:
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:
- Troque a versão no
requirements.txt. - Rode
docker compose build --no-cache pdf-service. - Teste (Testar uma alteração).
- Publique (Publicar e desfazer).
Testar uma alteração
Opção 1 — Python na sua máquina, sem Docker (Python 3.11 ou mais novo)
# 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
# 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:
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.
Opção 2 — No servidor, com Docker
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:
docker compose exec app wget -qO- http://pdf-service:8000/health
Checklist antes de publicar uma alteração:
GET /healthresponde"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-servicenã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:
cp -r pdf-service pdf-service.bak-$(date +%F)
Se der problema, volte a cópia e reconstrua:
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:docker compose logs --since 30m pdf-service -
Mais detalhes: troque
level=logging.INFOporlevel=logging.DEBUGemlogging.basicConfige acrescentelog.debug(...)onde precisar. Reconstrua o serviço depois. -
Inspecionar os links de um PDF: dentro do container, liste os links de um modelo:
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>.pdfMostra 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 serhttp://pdf-service:8000); - o log do app, que mostra o erro de conexão.
- se o container está
Deploy
Deploy, publicação no Cloudflare, atualização de versão, backup e solução de problemas estão no instrucoes.md (Parte 1).