diff --git a/.dockerignore b/.dockerignore index 7cfb81a..08789d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,5 @@ npm-debug.log* Dockerfile .dockerignore *.md +pdf-service +storage diff --git a/.gitignore b/.gitignore index 5ef6a52..9229560 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# PDFs gerados localmente (npm run dev) +/storage diff --git a/Dockerfile b/Dockerfile index 5427fc5..b18e7a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,9 +25,12 @@ ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ PORT=3000 \ HOSTNAME=0.0.0.0 \ - RUN_MIGRATIONS=true + RUN_MIGRATIONS=true \ + STORAGE_DIR=/storage RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs +# Ponto de montagem do volume de PDFs; o dono 1001 é copiado para o volume quando ele é criado +RUN mkdir -p /storage && chown nextjs:nodejs /storage # Servidor standalone + assets estáticos COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ diff --git a/MANUAL-USUARIO.md b/MANUAL-USUARIO.md index c2b904c..8c1f570 100644 --- a/MANUAL-USUARIO.md +++ b/MANUAL-USUARIO.md @@ -91,6 +91,7 @@ Clique em um evento para abrir os detalhes. - **Prazo para confirmar** (opcional) — último dia para responder - **Tema do convite** — a prévia logo abaixo mostra as cores e os ícones escolhidos - **Texto do selo** e **Texto do rodapé** (opcionais) — em branco, usam o texto padrão do tema + - **Imagem de fundo** (opcional) — JPG, PNG ou WebP, até **3 MB**. Aparece atrás do convite, com um véu claro por cima para o texto continuar legível, e também no card de pré-visualização do WhatsApp. Para tirar a imagem, marque **Remover a imagem de fundo atual** ao editar o evento. Prefira fotos leves: no WhatsApp, cards com imagem muito pesada podem aparecer sem foto - **Mensagem do WhatsApp** (opcional) — texto enviado pelo botão **WhatsApp** de cada convite. Em branco, usa a mensagem padrão (ver [2.5.1](#251-personalizar-a-mensagem-do-whatsapp)) 3. Clique em **Criar evento**. Você será levado à página do evento. @@ -231,14 +232,6 @@ Confirme a presença: {link} Quebras de linha e emojis também são mantidos. -**Emojis** — abaixo dos botões de variáveis há uma linha **Emojis:** com os mais usados em convites (🎉 🎈 🎂 🎁 📅 ⏰ 📍…) e os ícones dos temas. Clique em um para inseri-lo na posição do cursor. Para outros emojis, use o teclado de emojis do computador ou do celular: - -- **Windows:** tecla **Windows** + **.** (ponto); -- **Mac:** **Ctrl** + **Cmd** + **Espaço**; -- **Celular:** o botão de emoji do teclado. - -> Os emojis da linha **Emojis:** aparecem certos em praticamente qualquer celular. Emojis muito novos, escolhidos pelo teclado, podem aparecer como um quadrado em aparelhos desatualizados. Lembre também que cada emoji ocupa até 12 caracteres no link do WhatsApp (ver as regras abaixo). - **Exemplo:** ``` diff --git a/README.md b/README.md index 9c4f24d..6e2ace2 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,322 @@ -# Convite Safari 🦁 +# Convites Digitais -Convite digital de aniversário com confirmação de presença (RSVP) e área do organizador. +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. -- **Organizador** acessa `/admin` com senha, cadastra eventos e convites, compartilha os links (copiar / WhatsApp) e acompanha as respostas. +- **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 · Docker Compose +**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. --- -## Estrutura +## 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) - r/[token]/ Página do convite + Server Action do RSVP - admin/login/ Login do organizador - admin/(painel)/ Área do organizador (eventos e convites) - admin/actions.ts Server Actions do organizador + 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 - admin/ Formulários e componentes do organizador + 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 (events, invitations, guests) - index.ts Conexão com o Postgres - seed.ts Dados de exemplo (convites abc123 e xyz789) + 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 (cookie assinado) - invitation.ts Consultas do convite público - organizer.ts Consultas da área do organizador -drizzle/ Migrations SQL geradas pelo drizzle-kit -scripts/migrate.ts Aplica as migrations (usado no container) -Dockerfile Imagem de produção (Next standalone) -compose.yml App + Postgres + 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 | +| `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. + +### 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 -Copie `.env.example` para `.env` e preencha: +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 | Descrição | +| Variável | Uso no desenvolvimento | | --- | --- | -| `POSTGRES_DB` | Nome do banco criado pelo compose (padrão `convite`) | -| `POSTGRES_USER` | Usuário do banco (padrão `convite`) | -| `POSTGRES_PASSWORD` | Senha do banco. **Use só letras e números** (ela entra na URL de conexão) | -| `DATABASE_URL` | Conexão usada **fora** do compose (`npm run dev`, `db:migrate`, `db:seed`). No compose é sobrescrita automaticamente para o serviço `db` | -| `APP_PORT` | Porta publicada no servidor (padrão `3000`) | -| `ORGANIZER_PASSWORD` | Senha de acesso ao `/admin` | -| `SESSION_SECRET` | Chave para assinar o cookie de sessão (mínimo 32 caracteres) | -| `COOKIE_SECURE` | `true` quando servido via HTTPS (Cloudflare); `false` para acesso direto por `http://IP` | - -Para gerar o `SESSION_SECRET`: - -```bash -node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" -``` - -> ⚠️ Com `COOKIE_SECURE=true` o login **não funciona** em HTTP puro (ex.: `http://192.168.18.97:3000`). Só mude para `true` quando o acesso for pelo domínio HTTPS do Cloudflare. - ---- - -## Deploy no servidor (Docker Compose) - -### Pré-requisitos - -- Docker com o plugin Compose (`docker compose version`) -- Acesso à internet durante o build (o `next build` baixa as fontes do Google) - -### 1. Copiar o projeto para o servidor - -Copie a pasta do projeto (sem `node_modules`, `.next` e `dist`) para o servidor, por exemplo: - -```bash -rsync -av --exclude node_modules --exclude .next --exclude dist ./ usuario@192.168.18.97:/opt/convite/ -``` - -### 2. Configurar o `.env` - -No servidor, dentro da pasta do projeto: - -```bash -cp .env.example .env -nano .env -``` - -Preencha `POSTGRES_PASSWORD`, `ORGANIZER_PASSWORD` e `SESSION_SECRET`. Deixe `COOKIE_SECURE=false` enquanto o acesso for por IP. - -### 3. Subir - -```bash -docker compose up -d --build -``` - -O que acontece: - -1. O Postgres sobe e o app espera ele ficar saudável (healthcheck). -2. O container do app aplica as migrations pendentes (`drizzle/`) automaticamente. -3. O servidor Next sobe na porta `APP_PORT` (padrão `3000`). - -Acompanhe os logs: - -```bash -docker compose logs -f app -``` - -Deve aparecer `Migrations aplicadas.` seguido da inicialização do Next. - -### 4. Primeiro acesso - -1. Abra `http://192.168.18.97:3000/admin` e entre com a `ORGANIZER_PASSWORD`. -2. Cadastre o evento e os convites. -3. Use **Copiar link** ou **WhatsApp** para enviar cada convite. - -O banco começa vazio. Se quiser os convites de exemplo da POC, veja [Seed](#seed-dados-de-exemplo). - ---- - -## Atualizar a aplicação - -Depois de copiar a nova versão do código para o servidor: - -```bash -docker compose up -d --build app -``` - -As migrations novas são aplicadas na subida. Os dados do banco ficam no volume `pgdata` e **não são perdidos** ao recriar os containers. - -> Para desligar a migration automática, defina `RUN_MIGRATIONS=false` no `.env`. - ---- - -## Publicação via Cloudflare - -Quando o Cloudflare passar a apontar para o servidor (igual às outras aplicações): - -1. Aponte o domínio/túnel para a porta `APP_PORT` do servidor. -2. No `.env`, altere `COOKIE_SECURE=true`. -3. Recrie o app para aplicar: - - ```bash - docker compose up -d app - ``` - ---- - -## Backup e restauração do banco - -**Backup:** - -```bash -docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup-$(date +%F).sql -``` - -**Restauração** (em um banco vazio): - -```bash -docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' < backup-AAAA-MM-DD.sql -``` - -> ⚠️ `docker compose down -v` **apaga o volume do banco**. Use apenas `docker compose down` para parar os serviços. - ---- - -## Comandos úteis - -| Comando | O que faz | -| --- | --- | -| `docker compose ps` | Status dos containers | -| `docker compose logs -f app` | Logs do app | -| `docker compose restart app` | Reinicia o app | -| `docker compose down` | Para tudo (mantém os dados) | -| `docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"'` | Console SQL do banco | +| `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`). +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 @@ -189,33 +326,339 @@ 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`. +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. -### Scripts do banco +### 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/editar os dados | +| `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) | -### Seed (dados de exemplo) +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. -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, na máquina com o código e as dependências instaladas: +### Next.js 16 -```bash -npm run db:seed -``` +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`). --- -## Problemas comuns +## Como validar uma mudança -| Sintoma | Causa provável | +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 | | --- | --- | -| Login do `/admin` volta para a tela de login | `COOKIE_SECURE=true` com acesso por HTTP — use `false` | -| `SESSION_SECRET não definida` nos logs | Variável vazia ou com menos de 32 caracteres | -| `defina POSTGRES_PASSWORD no .env` ao subir | `POSTGRES_PASSWORD` ausente no `.env` | -| Erro de autenticação no Postgres após trocar a senha | A senha só é aplicada na **primeira** criação do volume; altere no banco ou recrie o volume (apaga os dados) | -| Página em branco no `npm run dev` acessando por IP | IP ausente em `allowedDevOrigins` no `next.config.ts` | +| `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). diff --git a/compose.yml b/compose.yml index 55f0057..aa63f0e 100644 --- a/compose.yml +++ b/compose.yml @@ -20,18 +20,48 @@ services: # ports: # - "127.0.0.1:5432:5432" + # Serviço Python que personaliza os PDFs dos convites. Sem porta publicada: só o app acessa, + # pela rede interna do compose (http://pdf-service:8000). + pdf-service: + build: ./pdf-service + restart: unless-stopped + environment: + STORAGE_DIR: /storage + # Base do link gravado em cada PDF: /r/. + # Opcional: vazia, o serviço usa o APP_URL. + APP_URL: ${APP_URL:-} + INVITE_LINK_BASE_URL: ${INVITE_LINK_BASE_URL:-} + # Links do PDF modelo que contêm este trecho são trocados pelo link do convite + INVITE_LINK_PATTERN: ${INVITE_LINK_PATTERN:-com.br/r/} + volumes: + - app-storage:/storage + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"] + interval: 10s + timeout: 5s + retries: 5 + app: build: . restart: unless-stopped depends_on: db: condition: service_healthy + # Só a geração de PDFs depende dele; o convite e o RSVP funcionam mesmo com ele fora do ar + pdf-service: + condition: service_started env_file: .env environment: # Sobrescreve o DATABASE_URL do .env: dentro do compose o host do banco é "db" DATABASE_URL: postgres://${POSTGRES_USER:-convite}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-convite} + PDF_SERVICE_URL: http://pdf-service:8000 + STORAGE_DIR: /storage + volumes: + # PDFs modelo e gerados (compartilhado com o pdf-service) + - app-storage:/storage ports: - "${APP_PORT:-3000}:3000" volumes: pgdata: + app-storage: diff --git a/drizzle/0007_event_background.sql b/drizzle/0007_event_background.sql new file mode 100644 index 0000000..8ff466a --- /dev/null +++ b/drizzle/0007_event_background.sql @@ -0,0 +1,2 @@ +ALTER TABLE "events" ADD COLUMN "background_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "background_type" text; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..4874d33 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,492 @@ +{ + "id": "a154e70d-84be-4724-817c-e8e4dfac48f8", + "prevId": "54db5cb2-3221-41c3-876d-2d4ed9fae526", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "venue": { + "name": "venue", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rsvp_deadline": { + "name": "rsvp_deadline", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safari'" + }, + "badge_text": { + "name": "badge_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "footer_text": { + "name": "footer_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "whatsapp_message": { + "name": "whatsapp_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_version": { + "name": "background_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "background_type": { + "name": "background_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_template_version": { + "name": "pdf_template_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pdf_template_name": { + "name": "pdf_template_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_template_updated_at": { + "name": "pdf_template_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guests": { + "name": "guests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invitation_id": { + "name": "invitation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "guest_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "rsvp_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "guests_invitation_id_invitations_id_fk": { + "name": "guests_invitation_id_invitations_id_fk", + "tableFrom": "guests", + "tableTo": "invitations", + "columnsFrom": [ + "invitation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pdf_status": { + "name": "pdf_status", + "type": "pdf_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'not_generated'" + }, + "pdf_template_version": { + "name": "pdf_template_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pdf_generated_at": { + "name": "pdf_generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pdf_error": { + "name": "pdf_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitations_event_id_events_id_fk": { + "name": "invitations_event_id_events_id_fk", + "tableFrom": "invitations", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizer_sessions": { + "name": "organizer_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdf_jobs": { + "name": "pdf_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "pdf_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "invitation_ids": { + "name": "invitation_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pdf_jobs_one_running_per_event": { + "name": "pdf_jobs_one_running_per_event", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pdf_jobs\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdf_jobs_event_id_events_id_fk": { + "name": "pdf_jobs_event_id_events_id_fk", + "tableFrom": "pdf_jobs", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.guest_type": { + "name": "guest_type", + "schema": "public", + "values": [ + "adult", + "teen", + "child" + ] + }, + "public.pdf_job_status": { + "name": "pdf_job_status", + "schema": "public", + "values": [ + "running", + "finished", + "interrupted" + ] + }, + "public.pdf_status": { + "name": "pdf_status", + "schema": "public", + "values": [ + "not_generated", + "generating", + "generated", + "error" + ] + }, + "public.rsvp_status": { + "name": "rsvp_status", + "schema": "public", + "values": [ + "pending", + "confirmed", + "declined" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1784c29..0c9b7af 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,48 @@ "when": 1790970071844, "tag": "0001_teen_and_rsvp_deadline", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1790991424550, + "tag": "0002_event_theme", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1790991649033, + "tag": "0003_event_custom_texts", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1791233146611, + "tag": "0004_organizer_sessions", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1791293904254, + "tag": "0005_pdf_generation", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1791378372969, + "tag": "0006_event_whatsapp_message", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1792548033540, + "tag": "0007_event_background", + "breakpoints": true } ] } \ No newline at end of file diff --git a/instrucoes.md b/instrucoes.md index fa865fd..3884594 100644 --- a/instrucoes.md +++ b/instrucoes.md @@ -1,9 +1,13 @@ -# Convite Safari 🦁 — Manual de Instruções +# Convites Digitais — Manual de Instruções -Este documento reúne **tudo o que é preciso para instalar, publicar e usar** o Convite Safari. +Este documento reúne **tudo o que é preciso para instalar, publicar e manter** os Convites Digitais no servidor. É para o **administrador do sistema**. -- **Parte 1 — Deploy:** para quem vai instalar e manter a aplicação no servidor. -- **Parte 2 — Uso:** para o organizador da festa e para os convidados. +- **Parte 1 — Deploy:** instalação, configuração, operação, backup e solução de problemas. +- **Parte 2 — Histórico de alterações:** o que mudou em cada versão e como atualizar o servidor. + +> Para **usar o sistema** (criar eventos e convites, enviar, acompanhar respostas, PDFs), veja o [MANUAL-USUARIO.md](MANUAL-USUARIO.md). Ele é escrito para o organizador da festa, sem termos técnicos, e pode ser entregue a quem vai gerenciar os eventos. +> +> Para **desenvolver ou alterar o código** (arquitetura, banco de dados, desenvolvimento local, serviço Python), veja o [README.md](README.md). --- @@ -23,12 +27,11 @@ Este documento reúne **tudo o que é preciso para instalar, publicar e usar** o 10. [Operação do dia a dia](#10-operação-do-dia-a-dia) 11. [Solução de problemas](#11-solução-de-problemas) -**Parte 2 — Uso** +**Parte 2 — Histórico de alterações** -12. [Conceitos](#12-conceitos) -13. [Área do organizador](#13-área-do-organizador) -14. [Experiência do convidado](#14-experiência-do-convidado) -15. [Perguntas frequentes](#15-perguntas-frequentes) +12. [Versão de 05/10/2026](#12-versão-de-05102026) +13. [Versão de 06/10/2026 — PDFs dos convites](#13-versão-de-06102026--pdfs-dos-convites) +14. [Versão de 07/10/2026 — Mensagem do WhatsApp personalizada](#14-versão-de-07102026--mensagem-do-whatsapp-personalizada) --- @@ -36,24 +39,31 @@ Este documento reúne **tudo o que é preciso para instalar, publicar e usar** o ## 1. Visão geral da arquitetura -A aplicação roda em **dois containers** gerenciados pelo Docker Compose: +A aplicação roda em **três containers** gerenciados pelo Docker Compose: | Serviço | Imagem | Função | | --- | --- | --- | | `app` | construída pelo `Dockerfile` do projeto | Servidor Next.js (páginas, área do organizador, RSVP) na porta `3000` | | `db` | `postgres:17-alpine` | Banco de dados. Os dados ficam no volume `pgdata` | +| `pdf-service` | construída por `pdf-service/Dockerfile` (Python) | Gera os PDFs personalizados dos convites. Só o `app` acessa | ``` Convidado / Organizador │ ▼ Cloudflare (HTTPS) ──► Servidor :3000 ──► container app ──► container db (Postgres) - └─ volume pgdata + │ └─ volume pgdata + │ HTTP interno (http://pdf-service:8000) + ▼ + container pdf-service + │ + volume app-storage ◄──────┴── (app e pdf-service: PDFs modelo e gerados) ``` Pontos importantes: -- O banco **não fica exposto** para fora do servidor; só o container `app` o acessa. +- O banco e o `pdf-service` **não ficam expostos** para fora do servidor; só o container `app` os acessa. +- Os PDFs (modelos e gerados) ficam no volume Docker `app-storage`, **fora do banco**. O banco guarda só as informações sobre eles. - As **migrations** (criação/atualização das tabelas) são aplicadas **automaticamente** toda vez que o container `app` sobe. - Os dados ficam no volume Docker `pgdata` e **sobrevivem** a reinícios e rebuilds dos containers. @@ -102,6 +112,7 @@ postcss.config.mjs drizzle.config.ts drizzle/ (migrations SQL) scripts/ (script de migration) +pdf-service/ (serviço Python de PDFs) src/ public/ ``` @@ -127,12 +138,17 @@ nano .env | `POSTGRES_PASSWORD` | **sim** | `Xk29fLq81` | Senha do banco. **Só letras e números** | | `DATABASE_URL` | não* | — | Usada apenas fora do Docker. No compose é montada automaticamente | | `APP_PORT` | não | `3000` | Porta publicada no servidor | +| `APP_URL` | recomendada | `https://convite.seudominio.com` | Endereço público do app, sem barra no final. Usado no card de pré-visualização do WhatsApp | | `ORGANIZER_PASSWORD` | **sim** | — | Senha de acesso à área do organizador | -| `SESSION_SECRET` | **sim** | — | Chave de 32+ caracteres para assinar a sessão | +| `SESSION_SECRET` | **sim** | — | Chave secreta de 32+ caracteres usada na verificação da senha | | `COOKIE_SECURE` | **sim** | `false` | `false` para acesso por `http://IP`; `true` atrás do Cloudflare (HTTPS) | | `RUN_MIGRATIONS` | não | `true` | `false` desliga a migration automática na subida | +| `INVITE_LINK_BASE_URL` | não | `https://convite.n1b3.com.br` | Endereço usado no link gravado em cada PDF: `/r/`, sem barra no final. **Em branco, usa o `APP_URL`.** Só defina se o link do PDF tiver de ser diferente do endereço do app | +| `INVITE_LINK_PATTERN` | não | `com.br/r/` | Trecho que identifica, no PDF modelo, o link a ser trocado pelo link de cada convite | +| `PDF_SERVICE_URL` | não* | `http://pdf-service:8000` | Endereço do serviço de PDFs. No compose é definido automaticamente | +| `STORAGE_DIR` | não* | `/storage` | Pasta dos PDFs. No compose é definida automaticamente (volume `app-storage`) | -\* Não precisa alterar para o deploy com Docker. +\* Não precisa alterar para o deploy com Docker. Essas variáveis só importam ao rodar fora do Docker (ver [README.md](README.md#variáveis-de-ambiente)). ### Gerar o `SESSION_SECRET` @@ -158,12 +174,17 @@ POSTGRES_USER=convite POSTGRES_PASSWORD=Xk29fLq81mPz APP_PORT=3000 +APP_URL=http://192.168.18.97:3000 ORGANIZER_PASSWORD=uma-senha-forte-para-o-admin SESSION_SECRET=3qD0x9bN1pY7sKcVb2T8wM4rH6jLz5aEoUfGiQ COOKIE_SECURE=false + +INVITE_LINK_PATTERN=com.br/r/ ``` +> Os PDFs usam o `APP_URL` como endereço do link. Para um endereço diferente, acrescente `INVITE_LINK_BASE_URL=https://...`. + > 🔒 O `.env` contém senhas. Não envie esse arquivo por e-mail/chat e restrinja o acesso: `chmod 600 .env`. --- @@ -176,8 +197,8 @@ docker compose up -d --build O que acontece, em ordem: -1. A imagem do `app` é construída (pode levar alguns minutos na primeira vez). -2. O Postgres sobe; o `app` aguarda o banco ficar saudável. +1. As imagens do `app` e do `pdf-service` são construídas (pode levar alguns minutos na primeira vez). +2. O Postgres e o `pdf-service` sobem; o `app` aguarda o banco ficar saudável. 3. O `app` aplica as migrations pendentes. 4. O servidor Next.js inicia na porta `APP_PORT`. @@ -185,7 +206,7 @@ O que acontece, em ordem: ## 6. Verificar se está tudo funcionando -**Status dos containers** — ambos devem estar `running` (e o `db` como `healthy`): +**Status dos containers** — os três devem estar `running` (e `db` e `pdf-service` como `healthy`): ```bash docker compose ps @@ -226,6 +247,7 @@ Quando o Cloudflare for apontar para a aplicação (mesmo padrão das outras apl ```dotenv COOKIE_SECURE=true + APP_URL=https:// ``` 3. Recrie o container do app: @@ -253,6 +275,7 @@ Os links de convite são gerados a partir do endereço em que o organizador est ``` 4. Verifique os logs (`docker compose logs -f app`). Novas migrations são aplicadas automaticamente. +5. Consulte o [Histórico de alterações](#parte-2--histórico-de-alterações) para saber se a versão pede algum passo extra (ex.: variável nova no `.env`). Os dados **não são perdidos** em atualizações. @@ -276,6 +299,22 @@ Gera um arquivo como `backup-2026-11-10.sql` na pasta atual. Guarde uma cópia * (crie a pasta antes: `mkdir -p /opt/convite/backups`) +### Backup dos PDFs + +Os PDFs modelo e gerados ficam no volume `app-storage`, **não** entram no `pg_dump`. Para copiá-los: + +```bash +docker run --rm -v convite_app-storage:/storage -v "$PWD":/backup alpine tar czf /backup/pdfs-$(date +%F).tar.gz -C /storage . +``` + +Para restaurar (com os containers parados): + +```bash +docker run --rm -v convite_app-storage:/storage -v "$PWD":/backup alpine sh -c 'tar xzf /backup/pdfs-AAAA-MM-DD.tar.gz -C /storage && chown -R 1001:1001 /storage' +``` + +> Os PDFs gerados podem ser refeitos a qualquer momento (botão **Gerar PDFs de todos os convites**). O que vale guardar é o **PDF modelo** de cada evento. + ### Restauração A restauração deve ser feita em um **banco vazio**. Para recriar o banco do zero: @@ -288,7 +327,7 @@ docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"' < back docker compose up -d app ``` -> ⚠️ **Nunca** use `docker compose down -v` sem querer apagar o banco — o `-v` remove o volume `pgdata`. +> ⚠️ **Nunca** use `docker compose down -v` sem querer apagar o banco — o `-v` remove os volumes `pgdata` e `app-storage` (PDFs). --- @@ -299,12 +338,14 @@ docker compose up -d app | Ver status | `docker compose ps` | | Ver logs do app | `docker compose logs -f app` | | Ver logs do banco | `docker compose logs -f db` | +| Ver logs do serviço de PDFs | `docker compose logs -f pdf-service` | +| Reiniciar o serviço de PDFs | `docker compose restart pdf-service` | | Reiniciar o app | `docker compose restart app` | | Parar tudo (mantém dados) | `docker compose down` | | Subir novamente | `docker compose up -d` | | Console SQL | `docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"'` | | Trocar a senha do organizador | Edite `ORGANIZER_PASSWORD` no `.env` e rode `docker compose up -d app` | -| Derrubar todas as sessões abertas | Troque o `SESSION_SECRET` no `.env` e rode `docker compose up -d app` | +| Derrubar todas as sessões abertas | No `/admin`, clique em **Sair de todos** (no topo da página) | Os containers estão com `restart: unless-stopped`: voltam sozinhos se o servidor reiniciar. @@ -320,186 +361,402 @@ Os containers estão com `restart: unless-stopped`: voltam sozinhos se o servido | Login do `/admin` sempre volta para a tela de login | `COOKIE_SECURE=true` com acesso via HTTP | Use `false` no acesso por IP, ou acesse pelo domínio HTTPS | | Erro `SESSION_SECRET não definida` no log | Variável vazia ou curta | Gere um valor de 32+ caracteres (seção 4) | | "Senha incorreta." no login | `ORGANIZER_PASSWORD` diferente da digitada | Confira o `.env` e recrie o app após alterar | +| "Muitas tentativas incorretas" no login | 5 senhas erradas em 15 minutos no mesmo IP | Aguarde o tempo indicado, ou reinicie o app (`docker compose restart app`) para zerar o bloqueio | +| Link colado no WhatsApp aparece sem imagem | `APP_URL` ausente ou errada, ou o WhatsApp guardou uma versão antiga | Ajuste `APP_URL` no `.env`, rode `docker compose up -d app` e teste com um convite que ainda não foi compartilhado | | Build falha baixando fontes/pacotes | Servidor sem internet no build | Libere o acesso à internet durante o `docker compose build` | | Porta 3000 em uso | Outra aplicação na mesma porta | Altere `APP_PORT` no `.env` | | Link do convite abre com o IP em vez do domínio | Link copiado acessando o admin pelo IP | Acesse o `/admin` pelo domínio e copie de novo | +| "Endereço do link não configurado" ao gerar PDF | Nem `APP_URL` nem `INVITE_LINK_BASE_URL` no `.env` | Defina `APP_URL` (seção 4) e rode `docker compose up -d pdf-service` | +| "Serviço de PDF indisponível" ao enviar modelo ou gerar PDF | Container `pdf-service` parado ou com erro | `docker compose ps` e `docker compose logs pdf-service`; suba com `docker compose up -d pdf-service` | +| "Nenhum link de convite (…/r/…) foi encontrado neste PDF" | O modelo não tem um link clicável com `INVITE_LINK_PATTERN` | Confira o link no PDF (precisa ser um link clicável, não só texto) ou ajuste `INVITE_LINK_PATTERN` | +| Convite com "Erro ao gerar" | Falha ao gerar aquele PDF | A mensagem aparece no card. Veja `docker compose logs app pdf-service` e clique em **Gerar novamente** | +| "Geração interrompida pela reinicialização do servidor" | O app reiniciou durante a geração | Clique em **Gerar só pendentes e com erro** | +| Erro de permissão (`Permission denied`) em `/storage` nos logs | Arquivos do volume com outro dono | `docker compose run --rm --user root --entrypoint chown pdf-service -R 1001:1001 /storage` | +| PDF baixado com link errado | `APP_URL` (ou `INVITE_LINK_BASE_URL`, se definida) errada | Corrija no `.env`, rode `docker compose up -d pdf-service` e gere os PDFs de novo | ---- +### 11.1 Diagnóstico dos PDFs -# Parte 2 — Uso - -## 12. Conceitos - -| Termo | Significado | +| Onde olhar | Comando | | --- | --- | -| **Evento** | A festa: nome, data, horário, local e endereço. Ex.: "1º Aniversário do Arthur" | -| **Convite** | Um link enviado para uma família/grupo. Tem um **nome exibido** (ex.: "Família Silva") e uma lista de convidados | -| **Convidado** | Cada pessoa do convite, marcada como **Adulto** ou **Criança** | -| **Token** | Código único no final do link (`/r/Ab3xY9kL`). Funciona como a "chave" do convite | -| **RSVP / Status** | Resposta de cada convidado: ✓ **Presente**, ✗ **Ausente** ou ⏳ **Pendente** | +| Saúde do serviço de PDFs | `docker compose ps pdf-service` (deve estar `healthy`) | +| Logs da geração (lado Python) | `docker compose logs -f pdf-service` — linhas `PDF gerado convite=...` ou o erro completo | +| Logs da geração (lado app) | `docker compose logs -f app` — linhas `Falha ao gerar o PDF do convite ...` | +| Arquivos no volume | `docker compose exec app ls -R /storage/events` | +| Lotes recentes | `docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB" -c "select status, processed, failed, total, created_at from pdf_jobs order by created_at desc limit 5"'` | +| Testar o serviço por dentro | `docker compose exec app wget -qO- http://pdf-service:8000/health` | -Um evento pode ter vários convites; cada convite pode ter vários convidados. +Para erros no código do serviço Python (traceback, links de um PDF, nível de log), veja "Manutenção do `pdf-service`" no [README.md](README.md#manutenção-do-pdf-service-código-python). --- -## 13. Área do organizador +# Parte 2 — Histórico de alterações -### 13.1 Entrar +## 12. Versão de 05/10/2026 -1. Acesse `https:///admin` (ou `http://192.168.18.97:3000/admin` antes da publicação). Também há o link discreto **"Área do organizador"** no rodapé da página inicial. -2. Digite a senha do organizador e clique em **Entrar**. +### 12.1 Resumo -A sessão dura **7 dias** no mesmo navegador. Para sair, clique em **Sair** no topo da página. +| # | Alteração | Para quem muda algo | Exige ação no deploy? | +| --- | --- | --- | --- | +| 1 | Visual neutro (Slate) na área do organizador e na página inicial | Organizador | Não | +| 2 | Limite de tentativas de login | Organizador | Não | +| 3 | Sessões guardadas no banco + botão **Sair de todos** | Organizador | Sim: migration automática; todos precisam entrar de novo | +| 4 | Nova variável `APP_URL` | Quem faz o deploy | Sim: adicionar ao `.env` | +| 5 | Card de pré-visualização do convite no WhatsApp | Convidado | Depende do item 4 | +| 6 | Exportação da lista de convidados em CSV | Organizador | Não | +| 7 | Projeto renomeado para **Convites Digitais** e documentação atualizada | Todos | Não | -### 13.2 Painel de eventos - -A tela inicial do organizador lista todos os eventos com: - -- data, horário e local; -- quantidade de convites; -- resumo de presença: **Presentes** (com divisão adultos/crianças), **Ausentes** e **Pendentes**. - -Clique em um evento para abrir os detalhes. - -### 13.3 Criar um evento - -1. No painel, clique em **+ Novo evento**. -2. Preencha: - - **Nome da festa** — ex.: "1º Aniversário do Arthur" - - **Data** e **Horário** - - **Local** — ex.: "Espaço Festa" - - **Endereço** — ex.: "Rua das Flores, 100 - Belo Horizonte/MG" -3. Clique em **Criar evento**. Você será levado à página do evento. - -### 13.4 Criar um convite - -1. Na página do evento, clique em **+ Novo convite**. -2. Preencha o **Nome exibido no convite** — é o título grande que o convidado vê. Ex.: "Família Silva", "Tio João e Tia Ana". -3. Adicione os convidados: - - digite o nome na primeira linha e escolha **Adulto** ou **Criança**; - - use **+ Adulto** / **+ Criança** para adicionar mais linhas; - - use **✕** para remover uma linha. - Linhas com o nome em branco são ignoradas. -4. Clique em **Criar convite**. - -O link do convite é gerado automaticamente. - -> 💡 Dica: crie **um convite por família/núcleo**. Assim cada família responde por todos os seus membros de uma vez. - -### 13.5 Enviar o convite - -Em cada convite, na página do evento: - -- **Copiar link** — copia o endereço completo (ex.: `https://seu-dominio/r/Ab3xY9kL`) para colar onde quiser. -- **WhatsApp** — abre o WhatsApp com uma mensagem pronta contendo o nome da família, o nome da festa e o link. Basta escolher o contato e enviar. -- **Abrir convite** — abre o convite em outra aba, exatamente como o convidado verá. - -> 🔒 Cada link dá acesso àquele convite e permite responder por ele. **Envie apenas para a família correspondente** e não publique em grupos abertos ou redes sociais. - -### 13.6 Acompanhar as respostas - -Na página do evento: - -- O **Resumo de presença** mostra o total de presentes (adultos e crianças), ausentes e pendentes de todo o evento — útil para fechar números com buffet e decoração. -- Cada convite mostra seus convidados com o status colorido: - - 🟢 **✓ Presente** - - 🔴 **✗ Ausente** - - 🟡 **⏳ Pendente** -- Quando a família responde, aparece **"respondeu em dd/mm/aaaa hh:mm"** abaixo do nome. - -Recarregue a página para ver respostas novas. - -### 13.7 Editar um convite - -1. No convite, clique em **Editar**. -2. Altere o nome exibido, renomeie convidados, troque o tipo, adicione ou remova pessoas. -3. Clique em **Salvar alterações**. - -O link **não muda** ao editar. O status de cada convidado aparece ao lado do nome durante a edição. - -> ⚠️ Remover um convidado apaga também a resposta dele. Convidados mantidos ou apenas renomeados **preservam** a resposta. - -### 13.8 Zerar as respostas de um convite - -Se uma família respondeu por engano e quer começar de novo: - -1. No convite, clique em **Zerar respostas** (só aparece se já houve resposta). -2. Confirme. - -Todos os convidados daquele convite voltam para **Pendente**. O link continua o mesmo. - -### 13.9 Excluir um convite - -1. No convite, clique em **Excluir** e confirme. - -O link deixa de funcionar imediatamente (o convidado verá "Convite não encontrado"). **Não é possível desfazer.** - -### 13.10 Editar ou excluir um evento - -1. Na página do evento, clique em **Editar evento**. -2. Altere os dados e clique em **Salvar alterações** — as mudanças aparecem na hora em todos os convites do evento. -3. Para excluir, use **Excluir evento** no final da página e confirme. - -> ⚠️ Excluir um evento apaga **todos os convites e respostas** dele. Não é possível desfazer. +Os passos para atualizar o servidor estão na [seção 12.9](#129-roteiro-de-atualização-desta-versão). --- -## 14. Experiência do convidado +### 12.2 Visual neutro na área do organizador -O convidado **não precisa de cadastro nem senha** — só do link recebido. +**Antes:** login, painel e página inicial usavam as cores e o 🦁 do tema Safári, mesmo em eventos com outro tema. -### 14.1 Abrir o convite +**Agora:** -Ao abrir o link, o convidado vê: +- Paleta **Slate / Grafite**: fundo cinza-claro liso, cards brancos, textos em cinza-azulado e botões principais em índigo. +- O 🦁 foi trocado por um ícone de calendário no login, no cabeçalho do painel e na página inicial. +- A página inicial agora diz "Convites Digitais" em vez de "Convites Safari". +- Contorno de foco, seleção de texto e checkboxes da área do organizador também usam índigo. +- A página **"Convite não encontrado"** ficou neutra (ícone de lupa, fundo cinza-claro). +- A cor da barra do navegador no celular agora é cinza-claro no app e **a cor do tema** na página de cada convite. -1. O **nome do convite** em destaque (ex.: "Família Silva — esperamos vocês para celebrar!"). -2. Os **detalhes da festa**: data, horário, local e endereço. -3. A **lista de convidados** daquele convite, cada um com seu status atual. -4. O quadro de **Confirmação de Presença**, com a contagem de presentes, ausentes e pendentes. +**O que continua igual, de propósito:** -### 14.2 Responder +- As cores de status: verde = presente, vermelho = ausente, âmbar = pendente. +- A prévia do tema no formulário de evento (mostra as cores do tema escolhido). +- O botão **WhatsApp** (verde). +- As páginas dos convites, que continuam com o tema escolhido para cada evento. -1. Para cada pessoa da lista, toque em: - - **✓** para confirmar presença, ou - - **✗** para informar ausência. - Para mudar de ideia antes de enviar, basta tocar no outro botão. -2. Toque em **Confirmar Presença**. -3. Aparece a tela **"Presença Confirmada!"** com o resumo de quem vai e quem não vai. Toque em **Fechar**. +**Arquivos:** `src/app/admin/**`, `src/components/admin/**`, `src/app/page.tsx`, `src/app/r/[token]/not-found.tsx`, `src/app/globals.css` (classe `.admin-ui`) e `src/components/ui/index.ts`. `Button` e `Input` ganharam a opção `tone="neutral"`; o padrão continua sendo as cores do tema, usadas no convite. -Não é obrigatório responder por todos de uma vez: quem ficar como pendente pode ser respondido depois. +--- -### 14.3 Mudar a resposta +### 12.3 Limite de tentativas de login -O convidado pode abrir o **mesmo link** a qualquer momento. As respostas já enviadas aparecem marcadas; basta trocar (ex.: de ✓ para ✗) e tocar em **Confirmar Presença** novamente. +**Antes:** a senha do `/admin` podia ser testada sem limite. -### 14.4 Mensagens possíveis +**Agora:** -| Mensagem | Significado | +- Depois de **5 senhas erradas em 15 minutos**, o login fica **bloqueado por 15 minutos** para aquele IP. +- Durante o bloqueio a senha nem é conferida, e a tela mostra quantos minutos faltam. +- Um login certo zera o contador. + +**Detalhes técnicos:** + +- Os contadores ficam **na memória do app**. Reiniciar o container (`docker compose restart app`) zera todos os bloqueios. +- O IP vem do cabeçalho `cf-connecting-ip`, enviado pelo Cloudflare. Sem ele, o app usa `x-forwarded-for` ou `x-real-ip`. +- ⚠️ Quem acessa o servidor **direto pelo IP**, sem passar pelo Cloudflare, consegue forjar esses cabeçalhos e escapar do limite. A proteção completa é o servidor aceitar só tráfego vindo do Cloudflare (Cloudflare Tunnel ou regra de firewall). + +**Arquivos:** `src/lib/login-rate-limit.ts` (novo) e `src/app/admin/auth-actions.ts`. + +--- + +### 12.4 Sessões no banco e "Sair de todos" + +**Antes:** o cookie de sessão se validava sozinho, por uma assinatura. **Sair** só apagava o cookie do navegador: uma cópia dele continuava valendo por até 7 dias. Para derrubar todas as sessões era preciso trocar o `SESSION_SECRET` e recriar o app. + +**Agora:** + +- Cada login cria uma linha na tabela nova **`organizer_sessions`**. O cookie guarda um token aleatório, e o banco guarda só o **hash** dele. +- Toda página e ação do organizador confere se a sessão ainda existe e não venceu (continua durando 7 dias). +- **Sair** apaga a sessão no banco, então uma cópia do cookie para de funcionar. +- **Sair de todos**, um botão novo no topo do painel com confirmação, apaga as sessões de todos os dispositivos. +- Sessões vencidas são apagadas a cada novo login. +- O `SESSION_SECRET` continua **obrigatório**: agora ele é usado na verificação da senha. + +**Impacto no deploy:** + +- Migration nova `drizzle/0004_organizer_sessions.sql`, aplicada automaticamente quando o container sobe. +- Os cookies antigos deixam de valer: **depois da atualização, todos precisam entrar de novo**. + +**Arquivos:** `src/db/schema.ts`, `src/lib/auth.ts`, `src/app/admin/auth-actions.ts`, `src/app/admin/(painel)/layout.tsx` e `drizzle/0004_organizer_sessions.sql`. + +--- + +### 12.5 Variável `APP_URL` + +**Antes:** o endereço base usado nos links das pré-visualizações estava fixo no código como `http://localhost:3000`. + +**Agora:** esse endereço vem da variável **`APP_URL`** do `.env`. + +| Situação | Valor de `APP_URL` | | --- | --- | -| **Convite não encontrado** | Link digitado errado, incompleto ou convite excluído pelo organizador | -| **Não foi possível enviar sua resposta. Tente novamente.** | Falha de conexão ou o convite foi alterado no meio do caminho. Recarregue a página e responda de novo | +| Acesso pelo IP (antes do Cloudflare) | `http://192.168.18.97:3000` | +| Depois da publicação no Cloudflare | `https://` | + +- Escreva **sem barra no final**. +- O valor é lido com o app rodando: depois de alterar, basta `docker compose up -d app` (sem rebuild). +- Se a variável faltar ou for inválida, o app usa `http://localhost:3000` e registra um aviso no log. Nesse caso o card do WhatsApp (12.6) fica sem imagem. + +**Arquivos:** `src/lib/app-url.ts` (novo), `src/app/layout.tsx` e `.env.example`. --- -## 15. Perguntas frequentes +### 12.6 Card de pré-visualização no WhatsApp -**O convidado precisa instalar algum aplicativo?** -Não. Funciona no navegador do celular ou do computador. +**Antes:** o link do convite colado no WhatsApp aparecia sem imagem e com um título genérico. -**Posso usar o mesmo sistema para várias festas?** -Sim. Crie um evento para cada festa; cada um tem seus próprios convites e totais. +**Agora:** aparece um card com: -**Duas pessoas da mesma família podem responder pelo mesmo link?** -Sim. Vale a última resposta enviada para cada convidado. +- **imagem** (1200×630) nas cores do tema do evento, com o selo e o ícone do tema, "*Família*, você está convidado para", o nome da festa, data · horário · local e "Toque para confirmar sua presença"; +- **título** "*Família*, você está convidado!"; +- **descrição** com o nome da festa, a data, o horário e o local. -**Um convidado novo apareceu depois que a família já respondeu. O que faço?** -Edite o convite e adicione a pessoa. Ela aparece como **Pendente** e as respostas anteriores dos outros são mantidas. Peça para a família abrir o link de novo e responder por ela. +**Detalhes:** -**Enviei o link errado para uma família. Como invalido?** -Exclua o convite e crie outro — o novo convite terá um link diferente. +- A imagem mostra só o que a página do convite já mostra para quem tem o link. **A lista de convidados não aparece.** +- A imagem é gerada na hora, então edições no evento aparecem nos próximos compartilhamentos. +- O WhatsApp **guarda o card de cada link por um tempo**: links compartilhados antes da atualização podem continuar sem imagem. +- Os emojis da imagem são baixados da internet ao gerar o card: o servidor precisa de acesso à internet. +- A mensagem pronta do botão **WhatsApp** agora usa o **ícone do tema do evento** (antes era sempre 🦁). -**Esqueci a senha do organizador.** -Quem administra o servidor consulta/altera `ORGANIZER_PASSWORD` no `.env` e recria o app (`docker compose up -d app`). +**Arquivos:** `src/app/r/[token]/opengraph-image.tsx` (novo), `src/app/r/[token]/page.tsx` e `src/components/admin/ShareInvitation.tsx`. -**Os dados ficam salvos se o servidor reiniciar?** -Sim. Os dados ficam no banco Postgres, em um volume persistente. Mesmo assim, mantenha backups (seção 9). +--- + +### 12.7 Exportar a lista de convidados (CSV) + +**Agora:** na página do evento, abaixo do **Resumo de presença**, em **Exportar lista (CSV)**, há os botões: + +| Botão | Conteúdo do arquivo | +| --- | --- | +| **Todos** | Todos os convidados do evento | +| **Presentes** | Só quem confirmou | +| **Ausentes** | Só quem recusou (e, depois do prazo, quem não respondeu) | +| **Pendentes** | Só quem ainda não respondeu | + +- **Colunas:** Convite, Convidado, Tipo (Adulto/Adolescente/Criança), Status e Respondido em (horário de Brasília). +- **Abre direto no Excel em português** e no Google Planilhas, com colunas e acentos corretos (separador `;`, UTF-8). +- **Nome do arquivo:** parte do nome da festa e do filtro, ex.: `1-aniversario-do-arthur-presentes.csv`. +- Só funciona com o organizador logado. Sem login, o endereço redireciona para a tela de login. +- Nomes que começam com `=`, `+`, `-` ou `@` recebem um apóstrofo na frente, para o Excel não tratá-los como fórmula. + +**Arquivos:** `src/app/admin/(painel)/eventos/[id]/exportar/route.ts` (novo) e `src/app/admin/(painel)/eventos/[id]/page.tsx`. + +--- + +### 12.8 Projeto renomeado e documentação + +- **Novo nome:** "Convite Safari" passou a se chamar **Convites Digitais** no `README.md`, no `instrucoes.md` e na página inicial. +- **Documentação atualizada:** o README e o `instrucoes.md`, que na época também trazia o manual de uso (hoje no `MANUAL-USUARIO.md`, ver 14.2), passaram a descrever o que já existia e não estava documentado (temas, prazo de confirmação, convidados adolescentes, textos de selo e rodapé) e as novidades desta versão. +- **Mantido:** o tema padrão no banco continua `safari`, só para eventos antigos criados antes da escolha de tema. O tema **Safári** continua disponível normalmente. + +--- + +### 12.9 Roteiro de atualização desta versão + +1. **Backup do banco** (esta versão cria uma tabela nova): + + ```bash + docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup-$(date +%F).sql + ``` + +2. **Copie o código novo** para o servidor (mesmo `rsync` da seção 3). +3. **Adicione `APP_URL` ao `.env`** do servidor (ver 12.5): + + ```dotenv + APP_URL=http://192.168.18.97:3000 + ``` + +4. **Reconstrua e suba o app:** + + ```bash + docker compose up -d --build app + ``` + +5. **Confira os logs** (`docker compose logs -f app`): deve aparecer `Migrations aplicadas.` e nenhum aviso de `APP_URL inválida`. +6. **Valide:** + - abra `/admin`: a tela de login deve estar no visual neutro; entre com a senha (as sessões antigas foram encerradas); + - abra um evento e baixe um CSV em **Exportar lista (CSV)**; + - cole o link de um convite **ainda não compartilhado** no WhatsApp e confira o card com imagem. +7. **Quando publicar no Cloudflare**, além de `COOKIE_SECURE=true`, troque `APP_URL` para `https://` e rode `docker compose up -d app`. + +--- + +## 13. Versão de 06/10/2026 — PDFs dos convites + +### 13.1 Resumo + +| # | Alteração | Para quem muda algo | Exige ação no deploy? | +| --- | --- | --- | --- | +| 1 | PDF modelo por evento (upload, substituição, download) | Organizador | Não | +| 2 | Geração do PDF personalizado por convite e em lote, com progresso | Organizador | Não | +| 3 | Novo container `pdf-service` (Python) e volume `app-storage` | Quem faz o deploy | Sim: build do novo serviço | +| 4 | Novas variáveis opcionais `INVITE_LINK_BASE_URL` (padrão: `APP_URL`) e `INVITE_LINK_PATTERN` | Quem faz o deploy | Só conferir se `APP_URL` está correto | +| 5 | Migration `0005_pdf_generation` (colunas de PDF e tabela `pdf_jobs`) | — | Automática na subida | + +Os passos para atualizar o servidor estão na [seção 13.3](#133-roteiro-de-atualização-desta-versão). + +--- + +### 13.2 O que mudou + +- **Para o organizador:** + - seção **PDF do convite** na página do evento; + - status e botões de PDF em cada convite; + - geração em lote com progresso. + + Uso detalhado na [seção 2.11 do MANUAL-USUARIO.md](MANUAL-USUARIO.md#211-pdfs-dos-convites). +- **No servidor:** + - container novo `pdf-service` (Python), sem porta publicada; + - volume novo `app-storage` com os PDFs, montado em `/storage` no `app` e no `pdf-service`; + - migration `0005_pdf_generation`, aplicada na subida. +- **Variáveis:** `INVITE_LINK_BASE_URL` (opcional; em branco usa o `APP_URL`) e `INVITE_LINK_PATTERN` (padrão `com.br/r/`). Ver [seção 4](#4-configurar-as-variáveis-de-ambiente). +- **Segurança:** + - downloads e ações exigem o organizador logado; + - o convite precisa pertencer ao evento da URL; + - o navegador nunca envia caminhos de arquivo; + - o upload aceita só PDF de até 10 MB. + +Arquitetura, banco, endpoints e manutenção do código estão no [README.md](README.md#pdfs-dos-convites). + +**Arquivos:** + +- **Novos:** + - `pdf-service/` (Dockerfile, `requirements.txt`, `app/main.py`); + - `src/lib/pdf/` (`storage.ts`, `service.ts`, `jobs.ts`, `state.ts`, `types.ts`); + - `src/app/admin/pdf-actions.ts`; + - `src/components/admin/EventPdf.tsx`; + - `src/instrumentation.ts`; + - `src/lib/slugify.ts`; + - rotas em `src/app/admin/(painel)/eventos/[id]/pdf/` e `.../convites/[conviteId]/pdf/`; + - `drizzle/0005_pdf_generation.sql`. +- **Alterados:** + - `src/db/schema.ts`; + - `src/app/admin/actions.ts` (apaga os PDFs ao excluir evento ou convite); + - página do evento; + - `next.config.ts` (limite de upload de 11 MB nas Server Actions); + - `Dockerfile`, `compose.yml`, `.env.example`, `.dockerignore` e `.gitignore`. + +--- + +### 13.3 Roteiro de atualização desta versão + +1. **Backup do banco** (esta versão altera tabelas): + + ```bash + docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup-$(date +%F).sql + ``` + +2. **Copie o código novo** para o servidor (mesmo `rsync` da seção 3). Confira que a pasta `pdf-service/` foi copiada. +3. **Confira o `.env`** do servidor: o `APP_URL` deve ser o endereço público (`https://convite.n1b3.com.br`), porque os PDFs usam esse endereço no link. As variáveis novas são opcionais: + + ```dotenv + # INVITE_LINK_BASE_URL=https://convite.n1b3.com.br # só se o link do PDF tiver de ser diferente do APP_URL + INVITE_LINK_PATTERN=com.br/r/ # padrão; pode omitir + ``` + +4. **Construa e suba tudo** (o `pdf-service` é novo): + + ```bash + docker compose up -d --build + ``` + +5. **Confira:** `docker compose ps` deve mostrar o `pdf-service` como `healthy`, e os logs do app devem mostrar `Migrations aplicadas.`. +6. **Valide:** + - abra um evento e envie o PDF modelo em **PDF do convite**; + - gere o PDF de um convite e baixe o arquivo; + - abra o PDF e clique no link: deve abrir `/r/`; + - clique em **Gerar PDFs de todos os convites** e acompanhe o progresso; + - rode `docker compose restart` e confira que os PDFs continuam disponíveis para download. + +--- + +## 14. Versão de 07/10/2026 — Mensagem do WhatsApp personalizada + +### 14.1 Resumo + +| # | Alteração | Para quem muda algo | Exige ação no deploy? | +| --- | --- | --- | --- | +| 1 | Campo **Mensagem do WhatsApp** no evento, com variáveis e formatação do WhatsApp | Organizador | Não | +| 2 | Pré-visualização da mensagem no formulário do evento | Organizador | Não | +| 3 | Migration `0006_event_whatsapp_message` (coluna `whatsapp_message` em `events`) | — | Automática na subida | +| 4 | Manual de uso separado em `MANUAL-USUARIO.md` | Organizador e administrador | Não | + +Eventos já existentes ficam com o campo vazio e passam a usar a **nova mensagem padrão** (ver 14.2) a partir dos próximos cliques no botão. Mensagens já enviadas não mudam. + +--- + +### 14.2 O que mudou + +**Antes:** a mensagem do botão **WhatsApp** era fixa no código: "Olá, *Família*! Vocês estão convidados para o *festa* *ícone*", seguida do link. + +**Agora:** + +- O formulário do evento tem o campo **Mensagem do WhatsApp**, com: + - botões para inserir as variáveis `{nome}`, `{evento}`, `{data}`, `{hora}`, `{local}`, `{endereco}`, `{icone}` e `{link}`; + - uma linha de ajuda com a formatação do WhatsApp; + - três contadores: **Modelo**, **Texto final (exemplo)** e **Link do WhatsApp (exemplo)**; + - uma **pré-visualização** no estilo do WhatsApp, que aplica negrito, itálico, riscado, monoespaçado e código. Em navegadores sem suporte (Safari < 16.4), a prévia aparece sem formatação, sem quebrar o formulário. +- O botão **WhatsApp** de cada convite monta o texto a partir do modelo do evento. +- Botão novo **Copiar mensagem** em cada convite: copia o mesmo texto do botão WhatsApp, para enviar por outro aplicativo. +- **Nova mensagem padrão**, usada com o campo vazio: + + ``` + Olá, {nome}! Chegou o seu convite para: *{evento}* {icone} + Confirme a presença: {link} + ``` + + A anterior ("…convidados para **o** *festa*") só combinava com nomes de festa masculinos no singular e com convites de família: "para o Festa da Maria" e "Vocês" num convite de uma pessoa só. A nova não depende do nome da festa nem de quantas pessoas há no convite. +- **Outros textos com a mesma correção:** + + | Onde | Antes | Agora | + | --- | --- | --- | + | Título do card do link no WhatsApp | "*Família*, você está convidado!" | "*Família*, chegou o seu convite!" | + | Imagem do card | "*Família*, você está convidado para" | "*Família*, chegou o seu convite para" | + | Topo da página do convite (e prévia do tema no formulário do evento) | "esperamos vocês para celebrar!" | "estamos esperando por você(s)!" | + + Links já compartilhados podem continuar mostrando o card antigo por um tempo, porque o WhatsApp guarda o card (ver 12.6). +- Sem `{link}` na mensagem, o link é adicionado no final. +- **Limite de 950 caracteres**, com margem abaixo de 1000 para o link do `wa.me`. Ele vale para o modelo e também para o texto final, já com as variáveis trocadas. + - O texto final é conferido no servidor ao criar ou editar o evento, com os nomes de todos os convites, e ao criar ou editar um convite. + - Nessa conferência, o link é estimado com o `APP_URL` e um código de 8 caracteres. + - A mensagem padrão (campo vazio) não é limitada. +- **Limite de 2300 caracteres no link codificado** (`https://wa.me/?text=...`), conferido nos mesmos momentos. Ele existe porque, no link, acentos ocupam 6 caracteres e emojis até 12. O WhatsApp não publica um limite oficial; os dois valores são margens de segurança. +- **Mensagem por evento:** não há mensagem diferente por convite. Para personalizar o texto de uma família, o organizador edita a mensagem no próprio WhatsApp antes de enviar. + +Uso detalhado na [seção 2.5.1 do MANUAL-USUARIO.md](MANUAL-USUARIO.md#251-personalizar-a-mensagem-do-whatsapp). + +**Arquivos:** + +- **Novos:** + - `src/lib/whatsapp-message.ts` (mensagem padrão, variáveis, limite e montagem do texto); + - `src/components/admin/WhatsAppMessageField.tsx` (campo, botões de variáveis e pré-visualização); + - `drizzle/0006_event_whatsapp_message.sql`. +- **Alterados:** + - `src/db/schema.ts` (coluna `whatsappMessage`); + - `src/app/admin/actions.ts` (leitura e validação do campo; conferência do texto final ao salvar evento e convite); + - `src/components/admin/EventForm.tsx` (campo novo; data, horário, local e endereço alimentam a pré-visualização); + - `src/components/admin/ShareInvitation.tsx` (usa o modelo do evento; botão Copiar mensagem); + - `src/app/r/[token]/page.tsx`, `src/app/r/[token]/opengraph-image.tsx` e `src/components/invitation/InvitationHero.tsx` (frases neutras); + - página do evento e página de edição do evento. + +**Documentação:** + +- O manual de uso, que era a antiga Parte 2 deste arquivo, foi movido para o [MANUAL-USUARIO.md](MANUAL-USUARIO.md). Ele foi reescrito sem termos técnicos e ganhou a seção **"Quando falar com o administrador"**. +- Este arquivo ficou só com o deploy (Parte 1) e o histórico, que passou a ser a Parte 2. As seções do histórico foram renumeradas de 16–18 para 12–14. + +--- + +### 14.3 Roteiro de atualização desta versão + +1. **Backup do banco** (esta versão adiciona uma coluna): + + ```bash + docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup-$(date +%F).sql + ``` + +2. **Copie o código novo** para o servidor (mesmo `rsync` da seção 3). +3. **Reconstrua e suba o app:** + + ```bash + docker compose up -d --build app + ``` + +4. **Confira os logs** (`docker compose logs -f app`): deve aparecer `Migrations aplicadas.`. +5. **Valide:** + - em um evento existente com o campo vazio, clique em **WhatsApp** num convite: deve aparecer a nova mensagem padrão ("Chegou o seu convite para: …"); + - em **Editar evento**, escreva uma mensagem com `*negrito*` e `{nome}` e confira a pré-visualização; + - salve, clique em **WhatsApp** e confira no WhatsApp o nome da família, a formatação e o link. diff --git a/next.config.ts b/next.config.ts index e5fa207..559bcc8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,12 @@ const nextConfig: NextConfig = { output: "standalone", // Permite acessar o dev server pela rede local (ex.: celular), não só por localhost allowedDevOrigins: ["192.168.18.97"], + experimental: { + serverActions: { + // Upload do PDF modelo (até 10 MB, PDF_TEMPLATE_MAX_MB em src/lib/pdf/types.ts) + folga do multipart + bodySizeLimit: "11mb", + }, + }, }; export default nextConfig; diff --git a/openspec-poc-convite-aniversario.md b/openspec-poc-convite-aniversario.md deleted file mode 100644 index 840d439..0000000 --- a/openspec-poc-convite-aniversario.md +++ /dev/null @@ -1,626 +0,0 @@ -# OpenSpec — Contexto e Diretrizes da POC do Convite de Aniversário - -## 1. Objetivo - -Construir uma POC (Proof of Concept) de um convite digital de aniversário de 1 ano utilizando Next.js. - -A POC deve validar a experiência visual e funcional do convite antes da implementação da infraestrutura completa de RSVP, banco de dados, autenticação administrativa, automações e integração com WhatsApp. - -O resultado deve parecer próximo de um produto real para permitir apresentação e aprovação da próxima fase. - -## 2. Contexto - -O produto final será uma plataforma de convites digitais personalizados. - -Cada família/convidado poderá receber um link exclusivo, por exemplo: - -`https://convite.exemplo.com/r/AbC123xyz` - -O convite poderá conter: -- nome do aniversariante; -- idade; -- data; -- horário; -- local; -- tema visual; -- mensagem personalizada; -- lista de convidados daquele convite; -- confirmação de presença. - -A POC não precisa implementar o backend definitivo. - -## 3. Escopo da POC - -### Deve possuir - -- Next.js; -- React; -- TypeScript; -- Tailwind CSS; -- página inicial; -- página `/r/[token]`; -- dados mockados em TypeScript/JSON; -- convite personalizado; -- lista de convidados; -- RSVP funcionando no frontend; -- feedback de confirmação; -- layout mobile-first; -- tema Safari. - -Exemplo de dados: - -```ts -{ - token: "abc123", - event: { - name: "1º Aniversário do Arthur", - date: "15 de novembro de 2026", - time: "16:00", - venue: "Espaço Festa", - address: "Belo Horizonte - MG" - }, - invitation: { - displayName: "Família Silva" - }, - guests: [ - { id: "1", name: "João", type: "adult", status: "pending" }, - { id: "2", name: "Maria", type: "adult", status: "pending" }, - { id: "3", name: "Pedro", type: "child", status: "pending" } - ] -} -``` - -## 4. RSVP - -O usuário deve conseguir: -- confirmar um convidado; -- recusar um convidado; -- alterar a seleção antes do envio; -- confirmar presença; -- visualizar uma mensagem de sucesso. - -Na POC não é obrigatório PostgreSQL. - -Pode usar React state ou localStorage. - -A interface deve ser preparada para futuramente substituir o mock por uma API. - -## 5. Fluxo - -```text -WhatsApp - ↓ -abre link personalizado - ↓ -convite - ↓ -dados da festa - ↓ -lista de convidados - ↓ -seleção de presença - ↓ -confirmar - ↓ -mensagem de sucesso -``` - -## 6. Design - -Tema inicial: **Safari / animais da selva**. - -Elementos possíveis: -- leão; -- elefante; -- girafa; -- folhas; -- árvores; -- animais infantis; -- natureza; -- ilustrações infantis. - -A aparência deve ser: -- infantil; -- elegante; -- alegre; -- moderna; -- adequada para aniversário de 1 ano. - -Evitar aparência genérica de formulário. - -## 7. Responsividade - -Mobile-first é obrigatório. - -Principal cenário: - -```text -WhatsApp → Android/iPhone → Browser → Convite -``` - -Também deve funcionar em tablet e desktop. - -## 8. Arquitetura da POC - -```text -Next.js -│ -├── páginas -├── componentes -├── dados mockados -└── camada de domínio -``` - -O acesso aos dados deve ser centralizado, permitindo futuramente: - -```text -Mock → API → PostgreSQL -``` - -Evitar espalhar dados mockados pelos componentes. - -## 9. Estrutura sugerida - -```text -app/ -├── page.tsx -├── r/ -│ └── [token]/ -│ └── page.tsx -└── globals.css - -components/ -├── invitation/ -│ ├── InvitationHero.tsx -│ ├── EventDetails.tsx -│ ├── GuestList.tsx -│ ├── RsvpSelector.tsx -│ ├── ConfirmationSuccess.tsx -│ └── InvitationFooter.tsx -└── ui/ - -data/ -└── invitations.ts - -types/ -└── invitation.ts - -lib/ -└── invitation.ts - -public/ -└── images/ -``` - -A estrutura pode ser alterada se o OpenSpec encontrar uma organização melhor. - -## 10. Modelo de domínio - -Criar tipos explícitos. - -```ts -export type GuestType = "adult" | "child"; - -export type RsvpStatus = - | "pending" - | "confirmed" - | "declined"; - -export interface Guest { - id: string; - name: string; - type: GuestType; - status: RsvpStatus; -} - -export interface InvitationEvent { - name: string; - date: string; - time: string; - venue: string; - address: string; -} - -export interface Invitation { - token: string; - event: InvitationEvent; - displayName: string; - guests: Guest[]; -} -``` - -## 11. Requisitos funcionais - -### RF01 — Acessar convite - -Permitir acessar: - -`/r/[token]` - -### RF02 — Personalização - -Apresentar o nome do grupo/convidado. - -Exemplo: - -> Família Silva, esperamos vocês! - -### RF03 — Dados do evento - -Exibir: -- nome; -- data; -- horário; -- local; -- endereço. - -### RF04 — Lista de convidados - -Exibir todos os convidados associados. - -### RF05 — Seleção - -Permitir: -- presente; -- não presente. - -### RF06 — Confirmação - -Validar que existe pelo menos uma resposta. - -Após confirmar: -- mostrar feedback; -- impedir envio duplicado enquanto processa; -- mostrar resumo das respostas. - -### RF07 — Estado - -Na POC o estado pode ficar no cliente. - -## 12. Requisitos não funcionais - -### RNF01 — Responsividade - -Mobile-first. - -### RNF02 — Performance - -Evitar bibliotecas pesadas sem necessidade. - -### RNF03 — Acessibilidade - -Utilizar: -- HTML semântico; -- labels; -- foco visível; -- contraste adequado; -- botões reais; -- navegação por teclado. - -### RNF04 — Componentização - -Evitar uma única página gigante. - -### RNF05 — Manutenibilidade - -Separar regras de negócio da apresentação sempre que fizer sentido. - -## 13. Fora do escopo da POC - -Não implementar ainda: -- PostgreSQL; -- Prisma; -- autenticação administrativa; -- Cloudflare Access; -- Cloudflare Tunnel; -- Nginx Proxy Manager; -- n8n; -- WhatsApp API; -- envio automático; -- geração de PDF; -- QR Code obrigatório; -- painel administrativo; -- analytics; -- cadastro real de convidados; -- upload administrativo; -- LGPD completa; -- sistema multi-evento. - -## 14. Preparação para evolução - -Manter a separação: - -```text -UI - ↓ -Domain / Model - ↓ -Data Source -``` - -Criar, por exemplo: - -```ts -getInvitationByToken(token) -``` - -Na POC: - -```ts -return mockInvitations.find(...) -``` - -Futuramente: - -```text -getInvitationByToken() - ↓ -API - ↓ -PostgreSQL -``` - -## 15. Token - -Na POC pode ser estático e sem segurança definitiva. - -No produto final: - -```text -randomBytes() - ↓ -token público - ↓ -SHA-256 - ↓ -tokenHash no banco -``` - -Não antecipar segurança complexa na POC. - -## 16. Critérios de aprovação - -A POC estará aprovada quando for possível demonstrar: - -### Cenário 1 - -Abrir `/r/abc123` e visualizar um convite completo. - -### Cenário 2 - -Abrir `/r/xyz789` e visualizar dados diferentes. - -### Cenário 3 - -Selecionar: - -```text -João → Confirmado -Maria → Confirmado -Pedro → Recusado -``` - -e enviar. - -### Cenário 4 - -Visualizar mensagem final com as respostas. - -### Cenário 5 - -Executar o fluxo corretamente em celular. - -## 17. Critérios visuais - -- primeira impressão de convite real; -- tema Safari consistente; -- boa hierarquia visual; -- animações discretas; -- textos legíveis; -- botões claros; -- RSVP intuitivo; -- boa experiência mobile; -- sem aparência de formulário administrativo; -- carregamento rápido. - -## 18. Diretriz para o OpenSpec - -Trabalhar em ciclos: - -```text -PROPOSE - ↓ -REVIEW - ↓ -APPLY - ↓ -VERIFY -``` - -Antes do APPLY, verificar: - -1. atende à POC; -2. não adiciona complexidade desnecessária; -3. não antecipa funcionalidades da fase 2; -4. contempla mobile; -5. permite substituir mock por API; -6. design adequado para aniversário; -7. componentes com responsabilidades claras. - -## 19. Regra principal - -Não implementar funcionalidades futuras apenas porque são tecnicamente interessantes. - -Exemplo: não adicionar PostgreSQL apenas porque ele será utilizado na versão final. - -A pergunta da POC é: - -> Este convite é suficientemente bom para obter aprovação? - -Somente depois da aprovação: - -```text -POC aprovada - ↓ -API - ↓ -PostgreSQL - ↓ -tokens reais - ↓ -admin - ↓ -Cloudflare - ↓ -automação -``` - -## 20. Fases planejadas - -### Fase 1 — POC visual - -```text -Next.js -React -TypeScript -Tailwind -Mock -``` - -Objetivo: aprovação do convite. - -### Fase 2 — Backend - -```text -Next.js API -PostgreSQL -Prisma -``` - -Objetivo: dados reais e RSVP persistente. - -### Fase 3 — Infraestrutura - -```text -Cloudflare Tunnel -Cloudflare Access -Rate limiting -Backups -``` - -Objetivo: publicação segura. - -### Fase 4 — Administração - -```text -/admin -``` - -Objetivo: gerenciar eventos, famílias e convidados. - -### Fase 5 — Automação - -```text -n8n -WhatsApp -lembretes -``` - -Objetivo: automatizar comunicação. - -## 21. Visão futura - -```text - WhatsApp - │ - ▼ - convite.n1b3.com.br - │ - ▼ - Cloudflare - │ - ▼ - Cloudflare Tunnel - │ - ▼ - Next.js - ┌───────┴────────┐ - │ │ - Web API - │ │ - └───────┬────────┘ - │ - Prisma - │ - ▼ - PostgreSQL - │ - ┌───────────┴───────────┐ - │ │ - RSVP Admin - │ │ - └───────────┬───────────┘ - │ - n8n - │ - ┌───────┼────────┐ - │ │ │ - WhatsApp Email Outros -``` - -## 22. Princípio central - -A POC deve ser: - -**pequena no escopo, mas com aparência de produto real.** - -O objetivo imediato é validar: - -```text -DESIGN - + -EXPERIÊNCIA - + -RSVP - + -PERSONALIZAÇÃO -``` - -A infraestrutura definitiva pode esperar. - -## 23. Resultado esperado - -Ao final da POC deverá existir uma aplicação Next.js executável localmente com: - -```text -/ -└── apresentação - -/r/abc123 -└── convite personalizado - -/r/xyz789 -└── outro convite personalizado -``` - -Fluxo: - -```text -abrir convite - ↓ -visualizar - ↓ -selecionar convidados - ↓ -confirmar - ↓ -feedback de sucesso -``` - -## 24. Decisão arquitetural registrada - -**POC = Next.js + React + TypeScript + Tailwind + dados mockados.** - -**Backend, PostgreSQL, Prisma, Cloudflare, autenticação, n8n e WhatsApp ficam fora do escopo da POC.** - -A POC deve permitir a substituição futura do mock por uma API sem refatoração completa da interface. diff --git a/package-lock.json b/package-lock.json index de5af72..adac537 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "dotenv": "^18.0.5", "drizzle-orm": "^0.45.3", + "lucide-react": "^1.52.0", "next": "16.3.8", "pg": "^8.23.1", "react": "19.2.8", @@ -2626,7 +2627,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3817,7 +3818,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6768,6 +6769,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz", + "integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index cd16bee..ec885ef 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "dotenv": "^18.0.5", "drizzle-orm": "^0.45.3", + "lucide-react": "^1.52.0", "next": "16.3.8", "pg": "^8.23.1", "react": "19.2.8", diff --git a/src/app/admin/(painel)/convites/[id]/editar/page.tsx b/src/app/admin/(painel)/convites/[id]/editar/page.tsx index 321d685..84974e4 100644 --- a/src/app/admin/(painel)/convites/[id]/editar/page.tsx +++ b/src/app/admin/(painel)/convites/[id]/editar/page.tsx @@ -16,11 +16,11 @@ export default async function EditInvitationPage({ params }: { params: Promise<{ return (
- + ← {invitation.event.name} -

Editar convite

-
+

Editar convite

+
- + ← {event.name} -

Novo convite

-
+

Novo convite

+
diff --git a/src/app/admin/(painel)/eventos/[id]/editar/page.tsx b/src/app/admin/(painel)/eventos/[id]/editar/page.tsx index 54805ea..a8e415f 100644 --- a/src/app/admin/(painel)/eventos/[id]/editar/page.tsx +++ b/src/app/admin/(painel)/eventos/[id]/editar/page.tsx @@ -16,11 +16,11 @@ export default async function EditEventPage({ params }: { params: Promise<{ id: return (
- + ← {event.name} -

Editar evento

-
+

Editar evento

+
diff --git a/src/app/admin/(painel)/eventos/[id]/fundo/route.ts b/src/app/admin/(painel)/eventos/[id]/fundo/route.ts new file mode 100644 index 0000000..a4096e6 --- /dev/null +++ b/src/app/admin/(painel)/eventos/[id]/fundo/route.ts @@ -0,0 +1,33 @@ +import { eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { db } from "@/db"; +import { events } from "@/db/schema"; +import { isBackgroundType } from "@/lib/background"; +import { requireOrganizer } from "@/lib/auth"; +import { isUuid } from "@/lib/organizer"; +import { backgroundPath, openPdf } from "@/lib/pdf/storage"; + +// GET /admin/eventos//fundo — imagem de fundo atual, para a prévia no formulário do evento +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + await requireOrganizer(); + const { id } = await params; + if (!isUuid(id)) notFound(); + + const event = await db.query.events.findFirst({ + where: eq(events.id, id), + columns: { backgroundVersion: true, backgroundType: true }, + }); + if (!event || !isBackgroundType(event.backgroundType)) notFound(); + + const file = await openPdf(backgroundPath(id, event.backgroundVersion, event.backgroundType)); + if (!file) notFound(); + + return new Response(file.stream, { + headers: { + "Content-Type": event.backgroundType, + "Content-Length": String(file.size), + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, no-store", + }, + }); +} diff --git a/src/app/admin/(painel)/eventos/[id]/page.tsx b/src/app/admin/(painel)/eventos/[id]/page.tsx index c2dcfc1..702c7ce 100644 --- a/src/app/admin/(painel)/eventos/[id]/page.tsx +++ b/src/app/admin/(painel)/eventos/[id]/page.tsx @@ -1,10 +1,21 @@ +import { AlarmClock, CalendarDays, Clock, Download, Lock, MapPin, Palette } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { deleteInvitation, resetInvitationRsvp } from "@/app/admin/actions"; -import { ConfirmActionButton, ShareInvitation, StatsBar } from "@/components/admin"; +import { uploadPdfTemplate } from "@/app/admin/pdf-actions"; +import { + ConfirmActionButton, + EventPdfPanel, + EventPdfProvider, + InvitationPdfActions, + ShareInvitation, + StatsBar, +} from "@/components/admin"; import { requireOrganizer } from "@/lib/auth"; import { formatShortDate, getEventWithInvitations } from "@/lib/organizer"; +import { getEventPdfState } from "@/lib/pdf/state"; +import { getTheme } from "@/lib/themes"; import type { GuestType, RsvpStatus } from "@/types/invitation"; export const metadata: Metadata = { title: "Evento" }; @@ -27,6 +38,14 @@ const typeIcons: Record = { child: "👶", }; +// Filtros da exportação CSV (rota ./exportar) +const exportOptions: { label: string; status?: RsvpStatus }[] = [ + { label: "Todos" }, + { label: "Presentes", status: "confirmed" }, + { label: "Ausentes", status: "declined" }, + { label: "Pendentes", status: "pending" }, +]; + const respondedFormatter = new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short", @@ -36,112 +55,165 @@ const respondedFormatter = new Intl.DateTimeFormat("pt-BR", { export default async function EventPage({ params }: { params: Promise<{ id: string }> }) { await requireOrganizer(); const { id } = await params; - const event = await getEventWithInvitations(id); - if (!event) notFound(); + const [event, pdfState] = await Promise.all([getEventWithInvitations(id), getEventPdfState(id)]); + if (!event || !pdfState) notFound(); return (
- + ← Eventos
-

{event.name}

-

- 📅 {formatShortDate(event.date)} · 🕐 {event.time} · 📍 {event.venue} — {event.address} +

{event.name}

+

+

+

+

{event.rsvpDeadline && ( -

- {event.rsvpClosed - ? `🔒 Confirmações encerradas em ${formatShortDate(event.rsvpDeadline)} — pendentes contam como ausentes` - : `⏰ Confirmações até ${formatShortDate(event.rsvpDeadline)}`} +

+ {event.rsvpClosed ? ( + <> +

)}
Editar evento
-
-

Resumo de presença

+
+

Resumo de presença

-
- -
-
-

- Convites ({event.invitations.length}) -

- - + Novo convite - +
+ + + {exportOptions.map((opt) => ( + + {opt.label} + + ))}
- - {event.invitations.length === 0 ? ( -

Nenhum convite criado para este evento.

- ) : ( - event.invitations.map((inv) => ( -
-
-
-

{inv.displayName}

-

- Link: /r/{inv.token} - {inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}} -

-
- -
- -
    - {inv.guests.map((g) => ( -
  • - {typeIcons[g.type]} {g.name} · {statusLabels[g.status]} -
  • - ))} -
- -
- - Editar - - - Abrir convite - - {inv.respondedAt && ( - - )} - -
-
- )) - )}
+ + {/* key: um novo modelo enviado recria o estado dos PDFs a partir do servidor */} + + [inv.id, inv.displayName]))} + /> + +
+
+

+ Convites ({event.invitations.length}) +

+ + + Novo convite + +
+ + {event.invitations.length === 0 ? ( +

Nenhum convite criado para este evento.

+ ) : ( + event.invitations.map((inv) => ( +
+
+
+

{inv.displayName}

+

+ Link: /r/{inv.token} + {inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}} +

+
+ +
+ +
    + {inv.guests.map((g) => ( +
  • + {typeIcons[g.type]} {g.name} · {statusLabels[g.status]} +
  • + ))} +
+ + + +
+ + Editar + + + Abrir convite + + {inv.respondedAt && ( + + )} + +
+
+ )) + )} +
+
); } diff --git a/src/app/admin/(painel)/eventos/novo/page.tsx b/src/app/admin/(painel)/eventos/novo/page.tsx index f8b6c1c..a5d3c9d 100644 --- a/src/app/admin/(painel)/eventos/novo/page.tsx +++ b/src/app/admin/(painel)/eventos/novo/page.tsx @@ -11,11 +11,11 @@ export default async function NewEventPage() { return (
- + ← Eventos -

Novo evento

-
+

Novo evento

+
diff --git a/src/app/admin/(painel)/layout.tsx b/src/app/admin/(painel)/layout.tsx index 3982764..e41ad7f 100644 --- a/src/app/admin/(painel)/layout.tsx +++ b/src/app/admin/(painel)/layout.tsx @@ -1,22 +1,35 @@ +import { CalendarHeart } from "lucide-react"; import Link from "next/link"; -import { logout } from "@/app/admin/auth-actions"; +import { logout, logoutEverywhere } from "@/app/admin/auth-actions"; +import { ConfirmActionButton } from "@/components/admin"; import { requireOrganizer } from "@/lib/auth"; export default async function PainelLayout({ children }: { children: React.ReactNode }) { await requireOrganizer(); return ( -
-
+
+
- - 🦁 Organizador + + + + Organizador -
- -
+
+ +
+ +
+
{children}
diff --git a/src/app/admin/(painel)/page.tsx b/src/app/admin/(painel)/page.tsx index 67cca2b..4fe0fea 100644 --- a/src/app/admin/(painel)/page.tsx +++ b/src/app/admin/(painel)/page.tsx @@ -1,3 +1,4 @@ +import { AlarmClock, CalendarDays, Clock, Lock, MapPin } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { StatsBar } from "@/components/admin"; @@ -13,35 +14,48 @@ export default async function AdminHome() { return (
-

Eventos

+

Eventos

+ Novo evento
{eventList.length === 0 ? ( -

Nenhum evento cadastrado ainda.

+

Nenhum evento cadastrado ainda.

) : (
{eventList.map((event) => (
-

{event.name}

- +

{event.name}

+ {event.invitationCount} {event.invitationCount === 1 ? "convite" : "convites"}
-

- 📅 {formatShortDate(event.date)} · 🕐 {event.time} · 📍 {event.venue} +

+

diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index 445eeda..623ad8b 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -1,6 +1,6 @@ "use server"; -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { and, eq, inArray, notInArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; @@ -8,6 +8,18 @@ import { db } from "@/db"; import { events, guests, invitations } from "@/db/schema"; import { requireOrganizer } from "@/lib/auth"; import { isUuid } from "@/lib/organizer"; +import { BACKGROUND_MAX_MB, detectBackgroundType, type BackgroundType } from "@/lib/background"; +import { removeEventFiles, removeGeneratedPdf, removeOtherBackgrounds, saveBackground } from "@/lib/pdf/storage"; +import { getAppUrl } from "@/lib/app-url"; +import { getTheme, isThemeId } from "@/lib/themes"; +import { + formatMessageDate, + renderWhatsAppMessage, + sampleInviteLink, + WHATSAPP_MESSAGE_MAX, + WHATSAPP_URL_MAX, + whatsappShareUrl, +} from "@/lib/whatsapp-message"; import type { GuestType } from "@/types/invitation"; export interface FormState { @@ -21,6 +33,9 @@ function field(formData: FormData, name: string): string { // ---------- Eventos ---------- +const BADGE_MAX = 40; +const FOOTER_MAX = 120; + function parseEvent(formData: FormData) { const data = { name: field(formData, "name"), @@ -29,6 +44,11 @@ function parseEvent(formData: FormData) { venue: field(formData, "venue"), address: field(formData, "address"), rsvpDeadline: field(formData, "rsvpDeadline") || null, + theme: field(formData, "theme"), + badgeText: field(formData, "badgeText") || null, + footerText: field(formData, "footerText") || null, + // Quebras de linha do textarea chegam como \r\n; o WhatsApp só precisa de \n + whatsappMessage: field(formData, "whatsappMessage").replace(/\r\n?/g, "\n") || null, }; if (!data.name || !data.date || !data.time || !data.venue || !data.address) { return { error: "Preencha todos os campos." } as const; @@ -36,6 +56,15 @@ function parseEvent(formData: FormData) { if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date) || !/^\d{2}:\d{2}$/.test(data.time)) { return { error: "Data ou horário inválido." } as const; } + if (!isThemeId(data.theme)) { + return { error: "Tema inválido." } as const; + } + if ((data.badgeText?.length ?? 0) > BADGE_MAX || (data.footerText?.length ?? 0) > FOOTER_MAX) { + return { error: `Textos do convite muito longos (selo até ${BADGE_MAX}, rodapé até ${FOOTER_MAX} caracteres).` } as const; + } + if ((data.whatsappMessage?.length ?? 0) > WHATSAPP_MESSAGE_MAX) { + return { error: `Mensagem do WhatsApp muito longa (até ${WHATSAPP_MESSAGE_MAX} caracteres).` } as const; + } if (data.rsvpDeadline !== null) { if (!/^\d{4}-\d{2}-\d{2}$/.test(data.rsvpDeadline)) { return { error: "Prazo de confirmação inválido." } as const; @@ -47,14 +76,105 @@ function parseEvent(formData: FormData) { return { data } as const; } +interface WhatsAppEventFields { + name: string; + date: string; + time: string; + venue: string; + address: string; + theme: string; + whatsappMessage: string | null; +} + +// Maiores tamanhos (texto final e link do wa.me) da mensagem personalizada do WhatsApp entre os +// nomes de convite informados. O link real usa a origem de quem clica; a estimativa usa o APP_URL. +// A mensagem padrão (campo vazio) não é limitada. +function measureWhatsAppMessage(event: WhatsAppEventFields, names: string[]): { chars: number; url: number } { + const size = { chars: 0, url: 0 }; + if (!event.whatsappMessage) return size; + const link = sampleInviteLink(getAppUrl().origin); + for (const nome of names) { + const text = renderWhatsAppMessage(event.whatsappMessage, { + nome, + evento: event.name, + data: formatMessageDate(event.date), + hora: event.time.slice(0, 5), + local: event.venue, + endereco: event.address, + icone: getTheme(event.theme).heroIcon, + link, + }); + size.chars = Math.max(size.chars, text.length); + size.url = Math.max(size.url, whatsappShareUrl(text).length); + } + return size; +} + +// "evento": conferido ao salvar o evento; "convite": ao salvar um convite (o nome entra em {nome}) +function whatsappLimitError(size: { chars: number; url: number }, context: "evento" | "convite"): string | null { + const prefix = context === "evento" ? "Com as variáveis trocadas" : "Com este nome"; + const fix = context === "evento" ? "Encurte a mensagem." : "Encurte o nome ou a mensagem do evento."; + if (size.chars > WHATSAPP_MESSAGE_MAX) { + return `${prefix}, a mensagem do WhatsApp fica com ${size.chars} caracteres (máximo ${WHATSAPP_MESSAGE_MAX}). ${fix}`; + } + if (size.url > WHATSAPP_URL_MAX) { + return `${prefix}, o link do WhatsApp fica com ${size.url} caracteres (máximo ${WHATSAPP_URL_MAX}): acentos e emojis ocupam mais espaço no link. ${fix}`; + } + return null; +} + +// ---------- Imagem de fundo ---------- + +interface BackgroundUpload { + bytes: Uint8Array; + type: BackgroundType; +} + +// Campos do formulário: "background" (arquivo, opcional) e "removeBackground" (marcado = tirar o fundo atual) +async function parseBackground( + formData: FormData +): Promise<{ error: string } | { upload: BackgroundUpload | null; remove: boolean }> { + const remove = formData.get("removeBackground") === "on"; + const file = formData.get("background"); + if (!(file instanceof File) || file.size === 0) return { upload: null, remove }; + + if (file.size > BACKGROUND_MAX_MB * 1024 * 1024) { + return { error: `A imagem de fundo deve ter no máximo ${BACKGROUND_MAX_MB} MB.` }; + } + const bytes = new Uint8Array(await file.arrayBuffer()); + const type = detectBackgroundType(bytes); + if (!type) return { error: "A imagem de fundo deve ser JPG, PNG ou WebP." }; + return { upload: { bytes, type }, remove: false }; +} + export async function createEvent(_prev: FormState, formData: FormData): Promise { await requireOrganizer(); const parsed = parseEvent(formData); if ("error" in parsed) return { error: parsed.error }; + // Ainda sem convites: confere só com os dados do evento + const whatsappError = whatsappLimitError(measureWhatsAppMessage(parsed.data, [""]), "evento"); + if (whatsappError) return { error: whatsappError }; - const [event] = await db.insert(events).values(parsed.data).returning({ id: events.id }); + const background = await parseBackground(formData); + if ("error" in background) return { error: background.error }; + + // O id nasce aqui para gravar o arquivo antes da linha: se o insert falhar, o arquivo é removido + const eventId = randomUUID(); + if (background.upload) { + await saveBackground(eventId, 1, background.upload.type, background.upload.bytes); + } + try { + await db.insert(events).values({ + ...parsed.data, + id: eventId, + ...(background.upload ? { backgroundVersion: 1, backgroundType: background.upload.type } : {}), + }); + } catch (err) { + await removeEventFiles(eventId).catch(() => undefined); + throw err; + } revalidatePath("/admin"); - redirect(`/admin/eventos/${event.id}`); + redirect(`/admin/eventos/${eventId}`); } export async function updateEvent(eventId: string, _prev: FormState, formData: FormData): Promise { @@ -62,11 +182,47 @@ export async function updateEvent(eventId: string, _prev: FormState, formData: F if (!isUuid(eventId)) return { error: "Evento inválido." }; const parsed = parseEvent(formData); if ("error" in parsed) return { error: parsed.error }; + if (parsed.data.whatsappMessage) { + const rows = await db + .select({ displayName: invitations.displayName }) + .from(invitations) + .where(eq(invitations.eventId, eventId)); + const names = rows.length ? rows.map((r) => r.displayName) : [""]; + const whatsappError = whatsappLimitError(measureWhatsAppMessage(parsed.data, names), "evento"); + if (whatsappError) return { error: whatsappError }; + } - await db.update(events).set(parsed.data).where(eq(events.id, eventId)); + const background = await parseBackground(formData); + if ("error" in background) return { error: background.error }; + const current = await db.query.events.findFirst({ + where: eq(events.id, eventId), + columns: { backgroundVersion: true, backgroundType: true }, + }); + if (!current) return { error: "Evento não encontrado." }; + + // A versão nunca volta atrás: a URL do fundo muda a cada troca e o cache dos convidados acompanha + let backgroundChange: { backgroundVersion: number; backgroundType: BackgroundType | null } | null = null; + if (background.upload) { + const version = current.backgroundVersion + 1; + await saveBackground(eventId, version, background.upload.type, background.upload.bytes); + backgroundChange = { backgroundVersion: version, backgroundType: background.upload.type }; + } else if (background.remove && current.backgroundType) { + backgroundChange = { backgroundVersion: current.backgroundVersion + 1, backgroundType: null }; + } + + await db + .update(events) + .set({ ...parsed.data, ...backgroundChange }) + .where(eq(events.id, eventId)); + if (backgroundChange) { + const keep = backgroundChange.backgroundType + ? { version: backgroundChange.backgroundVersion, type: backgroundChange.backgroundType } + : undefined; + await removeOtherBackgrounds(eventId, keep).catch((e) => console.error("Falha ao remover fundos antigos:", e)); + } revalidatePath("/admin"); revalidatePath(`/admin/eventos/${eventId}`); - // O prazo muda o que os convidados veem em todos os convites do evento + // O prazo e o tema mudam o que os convidados veem em todos os convites do evento revalidatePath("/r/[token]", "page"); redirect(`/admin/eventos/${eventId}`); } @@ -76,6 +232,8 @@ export async function deleteEvent(eventId: string): Promise { if (!isUuid(eventId)) return; await db.delete(events).where(eq(events.id, eventId)); + // PDFs (modelo e gerados) ficam no volume, fora do banco: apaga junto + await removeEventFiles(eventId).catch((e) => console.error("Falha ao apagar os PDFs do evento:", e)); revalidatePath("/admin"); redirect("/admin"); } @@ -121,11 +279,23 @@ function generateToken(): string { return randomBytes(6).toString("base64url"); } +// O nome do convite entra na mensagem do WhatsApp ({nome}); um nome longo pode estourar o limite +async function invitationWhatsAppError(eventId: string, displayName: string): Promise { + const event = await db.query.events.findFirst({ + where: eq(events.id, eventId), + columns: { name: true, date: true, time: true, venue: true, address: true, theme: true, whatsappMessage: true }, + }); + if (!event) return "Evento não encontrado."; + return whatsappLimitError(measureWhatsAppMessage(event, [displayName]), "convite"); +} + export async function createInvitation(eventId: string, _prev: FormState, formData: FormData): Promise { await requireOrganizer(); if (!isUuid(eventId)) return { error: "Evento inválido." }; const parsed = parseInvitation(formData); if ("error" in parsed) return { error: parsed.error }; + const whatsappError = await invitationWhatsAppError(eventId, parsed.displayName); + if (whatsappError) return { error: whatsappError }; await db.transaction(async (tx) => { const [invitation] = await tx @@ -162,6 +332,8 @@ export async function updateInvitation( columns: { id: true, eventId: true, token: true }, }); if (!invitation) return { error: "Convite não encontrado." }; + const whatsappError = await invitationWhatsAppError(invitation.eventId, parsed.displayName); + if (whatsappError) return { error: whatsappError }; await db.transaction(async (tx) => { await tx.update(invitations).set({ displayName: parsed.displayName }).where(eq(invitations.id, invitationId)); @@ -208,6 +380,7 @@ export async function deleteInvitation(invitationId: string): Promise { .where(eq(invitations.id, invitationId)) .returning({ eventId: invitations.eventId, token: invitations.token }); if (!deleted) return; + await removeGeneratedPdf(deleted.eventId, invitationId).catch((e) => console.error("Falha ao apagar o PDF do convite:", e)); revalidatePath("/admin"); revalidatePath(`/r/${deleted.token}`); diff --git a/src/app/admin/auth-actions.ts b/src/app/admin/auth-actions.ts index 73e0b69..003f97b 100644 --- a/src/app/admin/auth-actions.ts +++ b/src/app/admin/auth-actions.ts @@ -1,17 +1,34 @@ "use server"; import { redirect } from "next/navigation"; -import { checkOrganizerPassword, createSession, deleteSession } from "@/lib/auth"; +import { checkOrganizerPassword, createSession, deleteAllSessions, deleteSession, requireOrganizer } from "@/lib/auth"; +import { clearFailures, getClientIp, getLockoutMinutes, registerFailure } from "@/lib/login-rate-limit"; export interface LoginState { error?: string; } +function lockoutMessage(minutes: number): string { + return `Muitas tentativas incorretas. Tente novamente em ${minutes} ${minutes === 1 ? "minuto" : "minutos"}.`; +} + export async function login(_prev: LoginState, formData: FormData): Promise { + const ip = await getClientIp(); + + // Bloqueado: nem confere a senha, para não servir de oráculo durante o bloqueio + const lockedFor = getLockoutMinutes(ip); + if (lockedFor > 0) { + return { error: lockoutMessage(lockedFor) }; + } + const password = formData.get("password"); if (typeof password !== "string" || !checkOrganizerPassword(password)) { - return { error: "Senha incorreta." }; + registerFailure(ip); + const nowLocked = getLockoutMinutes(ip); + return { error: nowLocked > 0 ? lockoutMessage(nowLocked) : "Senha incorreta." }; } + + clearFailures(ip); await createSession(); redirect("/admin"); } @@ -20,3 +37,9 @@ export async function logout(): Promise { await deleteSession(); redirect("/admin/login"); } + +export async function logoutEverywhere(): Promise { + await requireOrganizer(); + await deleteAllSessions(); + redirect("/admin/login"); +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx index 5d027d5..e46e609 100644 --- a/src/app/admin/login/page.tsx +++ b/src/app/admin/login/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { CalendarHeart } from "lucide-react"; import { redirect } from "next/navigation"; import { LoginForm } from "@/components/admin"; import { isOrganizer } from "@/lib/auth"; @@ -11,11 +12,14 @@ export default async function LoginPage() { } return ( -
-
+
+
- 🦁 -

Área do Organizador

+
+
+

Área do Organizador

+

Gerencie seus eventos e convites

diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/globals.css b/src/app/globals.css index c049b95..8183c66 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -72,18 +72,32 @@ body { } :focus-visible { - outline: 2px solid #f59e0b; + outline: 2px solid var(--color-amber-500); outline-offset: 2px; } ::selection { - background-color: #f59e0b; + background-color: var(--color-amber-500); color: white; } input[type="checkbox"], input[type="radio"] { - accent-color: #f59e0b; + accent-color: var(--color-amber-500); +} + +/* Área do organizador e página inicial: paleta Slate fixa, independente do tema do convite */ +.admin-ui :focus-visible { + outline-color: var(--color-indigo-500); +} + +.admin-ui ::selection { + background-color: var(--color-indigo-600); +} + +.admin-ui input[type="checkbox"], +.admin-ui input[type="radio"] { + accent-color: var(--color-indigo-600); } ::-webkit-scrollbar { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b2cde9d..11c5092 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { getAppUrl } from "@/lib/app-url"; import "./globals.css"; const geistSans = Geist({ @@ -14,22 +15,23 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: { - default: "Convite Safari - Aniversário de 1 Ano", - template: "%s | Convite Safari", + default: "Convite", + template: "%s | Convite", }, - description: "Convite digital personalizado com tema Safari para aniversário de 1 ano. RSVP funcional e design mobile-first.", - metadataBase: new URL("http://localhost:3000"), + description: "Convite digital personalizado com confirmação de presença online.", + // Base para os links absolutos das pré-visualizações (WhatsApp, redes sociais) + metadataBase: getAppUrl(), openGraph: { type: "website", locale: "pt_BR", - siteName: "Convite Safari", - title: "Convite Safari - Aniversário de 1 Ano", - description: "Convite digital personalizado com tema Safari para aniversário de 1 ano.", + siteName: "Convite", + title: "Convite", + description: "Convite digital personalizado com confirmação de presença online.", }, twitter: { card: "summary_large_image", - title: "Convite Safari", - description: "Convite digital personalizado com tema Safari para aniversário de 1 ano.", + title: "Convite", + description: "Convite digital personalizado com confirmação de presença online.", }, robots: { index: false, @@ -38,7 +40,7 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - themeColor: "#f59e0b", + themeColor: "#f8fafc", width: "device-width", initialScale: 1, maximumScale: 5, diff --git a/src/app/page.tsx b/src/app/page.tsx index c34aaea..da532b0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,22 +1,23 @@ +import { CalendarHeart } from "lucide-react"; import Link from "next/link"; // Página pública neutra: os convites só são acessados pelo link /r/, // e a listagem fica na área do organizador (/admin) export default function Home() { return ( -
+
-
- 🦁 - Convites Safari +
+
-

+

Convites Digitais de Aniversário

-

+

Recebeu um convite? Abra o link que foi enviado para você para confirmar sua presença.

- + Área do organizador
diff --git a/src/app/r/[token]/fundo/route.ts b/src/app/r/[token]/fundo/route.ts new file mode 100644 index 0000000..9a521b3 --- /dev/null +++ b/src/app/r/[token]/fundo/route.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { db } from "@/db"; +import { invitations } from "@/db/schema"; +import { isBackgroundType } from "@/lib/background"; +import { backgroundPath, openPdf } from "@/lib/pdf/storage"; + +// GET /r//fundo?v= — imagem de fundo do evento do convite. +// Pública como o próprio convite: quem tem o link já vê a imagem. O caminho nunca vem do navegador. +export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + const invitation = await db.query.invitations.findFirst({ + where: eq(invitations.token, token), + columns: {}, + with: { event: { columns: { id: true, backgroundVersion: true, backgroundType: true } } }, + }); + const event = invitation?.event; + if (!event || !isBackgroundType(event.backgroundType)) notFound(); + + const file = await openPdf(backgroundPath(event.id, event.backgroundVersion, event.backgroundType)); + if (!file) notFound(); + + // Com a versão certa na URL o conteúdo nunca muda; sem ela (ou antiga), cache curto + const requested = new URL(request.url).searchParams.get("v"); + const immutable = requested === String(event.backgroundVersion); + return new Response(file.stream, { + headers: { + "Content-Type": event.backgroundType, + "Content-Length": String(file.size), + "X-Content-Type-Options": "nosniff", + "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "public, max-age=300", + }, + }); +} diff --git a/src/app/r/[token]/not-found.tsx b/src/app/r/[token]/not-found.tsx index 22169bb..91d7ea7 100644 --- a/src/app/r/[token]/not-found.tsx +++ b/src/app/r/[token]/not-found.tsx @@ -1,20 +1,21 @@ +import { SearchX } from "lucide-react"; import Link from "next/link"; export default function NotFound() { return ( -
+
-
- 🦁 +
+
-

Convite não encontrado

-

+

Convite não encontrado

+

O link que você acessou não corresponde a nenhum convite válido. Verifique se o link está correto ou entre em contato com os organizadores.

diff --git a/src/app/r/[token]/opengraph-image.tsx b/src/app/r/[token]/opengraph-image.tsx index f7807b7..dd3d1ff 100644 --- a/src/app/r/[token]/opengraph-image.tsx +++ b/src/app/r/[token]/opengraph-image.tsx @@ -1,6 +1,6 @@ import { ImageResponse } from "next/og"; import { connection } from "next/server"; -import { getInvitationByToken } from "@/lib/invitation"; +import { getBackgroundDataUri, getInvitationByToken } from "@/lib/invitation"; import { getTheme } from "@/lib/themes"; // Imagem do card quando o link do convite é colado no WhatsApp ou em redes sociais. @@ -40,6 +40,7 @@ export default async function Image({ params }: { params: Promise<{ token: strin } const { event } = invitation; + const background = await getBackgroundDataUri(token); return new ImageResponse( (
+ {background && ( + <> + {/* Foto cobrindo o card, com véu claro por cima para o texto continuar legível */} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + )}
; } +// Texto do card de pré-visualização (WhatsApp, redes sociais). A imagem vem do opengraph-image.tsx. +export async function generateMetadata({ params }: InvitationPageProps): Promise { + await connection(); + const { token } = await params; + const invitation = await getInvitationByToken(token); + if (!invitation) return { title: "Convite não encontrado" }; + + const { event } = invitation; + const title = `${invitation.displayName}, chegou o seu convite!`; + const description = `${event.name} · ${event.date} às ${event.time} · ${event.venue}. Confirme sua presença pelo link.`; + return { + title: event.name, + description, + openGraph: { type: "website", locale: "pt_BR", siteName: "Convite", title, description }, + twitter: { card: "summary_large_image", title, description }, + }; +} + +// Barra do navegador no celular com a cor do tema do convite +export async function generateViewport({ params }: InvitationPageProps): Promise { + await connection(); + const { token } = await params; + const invitation = await getInvitationByToken(token); + return { themeColor: getTheme(invitation?.theme).palette[500] }; +} + export default async function InvitationPage({ params }: InvitationPageProps) { // Sempre renderiza na hora: o convite muda com RSVPs e edições do organizador await connection(); diff --git a/src/components/admin/ConfirmActionButton.tsx b/src/components/admin/ConfirmActionButton.tsx index 3ba1d77..727cf25 100644 --- a/src/components/admin/ConfirmActionButton.tsx +++ b/src/components/admin/ConfirmActionButton.tsx @@ -26,7 +26,7 @@ export function ConfirmActionButton({ const className = variant === "danger" ? "text-sm px-3 py-1.5 rounded-lg border border-rose-300 text-rose-700 hover:bg-rose-50 disabled:opacity-50" - : "text-sm px-3 py-1.5 rounded-lg border border-amber-300 text-amber-700 hover:bg-amber-50 disabled:opacity-50"; + : "text-sm px-3 py-1.5 rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 disabled:opacity-50"; return (
Promise; @@ -14,49 +18,245 @@ interface EventFormProps { venue: string; address: string; rsvpDeadline: string | null; + theme: string; + badgeText: string | null; + footerText: string | null; + whatsappMessage: string | null; + // URL da imagem de fundo atual (rota do admin), ou null se o evento não tem fundo + backgroundUrl: string | null; }; } export function EventForm({ action, submitLabel, defaults }: EventFormProps) { const [state, formAction, pending] = useActionState(action, {}); + const [themeId, setThemeId] = useState(defaults?.theme ?? DEFAULT_THEME_ID); + const theme = getTheme(themeId); + const [badgeText, setBadgeText] = useState(defaults?.badgeText ?? ""); + const [footerText, setFooterText] = useState(defaults?.footerText ?? ""); + const [eventName, setEventName] = useState(defaults?.name ?? ""); + const [date, setDate] = useState(defaults?.date ?? ""); + const [time, setTime] = useState(defaults?.time ?? ""); + const [venue, setVenue] = useState(defaults?.venue ?? ""); + const [address, setAddress] = useState(defaults?.address ?? ""); + const [whatsappMessage, setWhatsappMessage] = useState(defaults?.whatsappMessage ?? ""); + const [newBackground, setNewBackground] = useState(null); + const [removeBackground, setRemoveBackground] = useState(false); + const [backgroundError, setBackgroundError] = useState(null); + + // Libera a URL temporária da prévia ao trocar de arquivo ou sair da página + useEffect(() => { + return () => { + if (newBackground) URL.revokeObjectURL(newBackground); + }; + }, [newBackground]); + + const handleBackgroundChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + setBackgroundError(null); + if (!file) { + setNewBackground(null); + return; + } + if (file.size > BACKGROUND_MAX_MB * 1024 * 1024) { + setBackgroundError(`A imagem deve ter no máximo ${BACKGROUND_MAX_MB} MB.`); + e.target.value = ""; + setNewBackground(null); + return; + } + setRemoveBackground(false); + setNewBackground(URL.createObjectURL(file)); + }; + + const currentBackground = removeBackground ? null : (newBackground ?? defaults?.backgroundUrl ?? null); return (
+ +
+ + +
+ Em branco, usa o texto padrão do tema. +
+ + + + JPG, PNG ou WebP, até {BACKGROUND_MAX_MB} MB. Aparece atrás do convite, com um véu claro por cima, e também + na prévia do link no WhatsApp. + + {backgroundError && ( + + {backgroundError} + + )} + {currentBackground && ( + // eslint-disable-next-line @next/next/no-img-element + Prévia da imagem de fundo + )} + {defaults?.backgroundUrl && !newBackground && ( + + )} +
+ {/* Prévia: as classes amber-* abaixo assumem as cores do tema escolhido */} +
+ + {theme.heroIcon} + {badgeText.trim() || theme.badge} + +

Família Exemplo

+

estamos esperando por você(s)! {theme.dividerIcon}

+ +

+ {footerText.trim() || defaultFooterText(eventName || "Nome da festa")} +

+
+ {state.error && (

{state.error}

)} - diff --git a/src/components/admin/InvitationForm.tsx b/src/components/admin/InvitationForm.tsx index e7e107b..0852773 100644 --- a/src/components/admin/InvitationForm.tsx +++ b/src/components/admin/InvitationForm.tsx @@ -46,8 +46,9 @@ export function InvitationForm({ action, submitLabel, defaults }: InvitationForm return (
-
-

{guest.name}

- - {guest.status === "confirmed" && "✓ Presente"} - {guest.status === "declined" && "✗ Ausente"} - {guest.status === "pending" && "⏳ Pendente"} - -
-

{typeLabels[guest.type]}

+

{guest.name}

+ + {guest.status === "confirmed" && "✓ Presente"} + {guest.status === "declined" && "✗ Ausente"} + {guest.status === "pending" && "⏳ Pendente"} + +

{typeLabels[guest.type]}

{!readOnly && ( diff --git a/src/components/invitation/InvitationFooter.tsx b/src/components/invitation/InvitationFooter.tsx index 14db4bb..871f609 100644 --- a/src/components/invitation/InvitationFooter.tsx +++ b/src/components/invitation/InvitationFooter.tsx @@ -1,17 +1,21 @@ -export function InvitationFooter() { +import type { InvitationTheme } from "@/lib/themes"; + +export function InvitationFooter({ theme, footerText }: { theme: InvitationTheme; footerText: string }) { return (
- 🌿 - 🦁 - 🌿 + {theme.footerIcons.map((icon, index) => ( + + {icon} + + ))}

Convite digital personalizado

- Feito com carinho para o 1º Aniversário + {footerText}

); -} \ No newline at end of file +} diff --git a/src/components/invitation/InvitationHero.tsx b/src/components/invitation/InvitationHero.tsx index 098edcd..95987dd 100644 --- a/src/components/invitation/InvitationHero.tsx +++ b/src/components/invitation/InvitationHero.tsx @@ -1,20 +1,26 @@ +import type { InvitationTheme } from "@/lib/themes"; + export function InvitationHero({ displayName, + theme, + badgeText, }: { displayName: string; + theme: InvitationTheme; + badgeText?: string; }) { const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23f59e0b' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"; return (
-
-
+
+
- 🦁 - Safari Party + {theme.heroIcon} + {badgeText ?? theme.badge}

@@ -22,12 +28,12 @@ export function InvitationHero({

- esperamos vocês para celebrar! + estamos esperando por você(s)!

- 🌿 + {theme.dividerIcon}
diff --git a/src/components/invitation/InvitationView.tsx b/src/components/invitation/InvitationView.tsx index b043b61..392d42f 100644 --- a/src/components/invitation/InvitationView.tsx +++ b/src/components/invitation/InvitationView.tsx @@ -8,6 +8,7 @@ import { RsvpSelector } from "./RsvpSelector"; import { ConfirmationSuccess } from "./ConfirmationSuccess"; import { InvitationFooter } from "./InvitationFooter"; import { submitRsvp } from "@/app/r/[token]/actions"; +import { defaultFooterText, getTheme, themeStyle } from "@/lib/themes"; import type { Invitation, Guest } from "@/types/invitation"; export function InvitationView({ invitation }: { invitation: Invitation }) { @@ -17,6 +18,7 @@ export function InvitationView({ invitation }: { invitation: Invitation }) { const [submitError, setSubmitError] = useState(false); const [closed, setClosed] = useState(invitation.rsvpClosed); + const theme = getTheme(invitation.theme); const hasChanges = guests.some((g) => g.status !== "pending"); const handleGuestToggle = (guestId: string, status: "confirmed" | "declined") => { @@ -55,9 +57,23 @@ export function InvitationView({ invitation }: { invitation: Invitation }) { }; return ( -
-
- +
+ {invitation.backgroundUrl && ( + // Camada fixa (e não background-attachment: fixed, que falha no iOS) com um véu claro por cima, + // para o texto continuar legível com qualquer imagem +