Files
convite-rael/README.md

36 KiB
Raw Blame History

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 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
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 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)

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:

  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, 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):

  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:

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:

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).
  4. 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 /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:

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.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:

    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 (Parte 1).