# 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/` 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/` 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/`: 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=`). 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//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//template/modelo-v.pdf troca os links que contêm INVITE_LINK_PATTERN por /r/ grava /storage/events//generated/.pdf ``` - **Armazenamento** (`src/lib/pdf/storage.ts` e `pdf-service/app/main.py`, com a mesma estrutura): ``` /storage/events//template/modelo-v.pdf PDF modelo (só a versão atual fica no disco) /storage/events//generated/.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.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//pdf/status` (JSON; 401 sem sessão) | | Download do modelo | GET | `/admin/eventos//pdf/modelo` | | Download do PDF do convite | GET | `/admin/eventos//convites//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//template/modelo-v.pdf` | | `_generated_path(event_id, invitation_id)` | Monta `/storage/events//generated/.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: `/r/` (ou `/r/`, 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": "", "template_version": 3}` | `{"links": 1}` | | `POST /invitations/generate` | `{"event_id": "", "invitation_id": "", "token": "Ab3xY9kL", "template_version": 3}` | `{"links": 1}` | **Erros:** o serviço responde `{"detail": ""}`. 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 `/r/` (ou `/r/`, 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//template/modelo-v.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).