diff --git a/MANUAL-USUARIO.md b/MANUAL-USUARIO.md new file mode 100644 index 0000000..c2b904c --- /dev/null +++ b/MANUAL-USUARIO.md @@ -0,0 +1,550 @@ +# Convites Digitais — Manual do Usuário + +Este manual explica como **criar eventos, montar e enviar convites e acompanhar as respostas**. É para o **organizador da festa** e não exige conhecimento técnico. A seção 3 mostra o que o **convidado** vê ao abrir o link. + +> A instalação e a manutenção do servidor ficam com o **administrador do sistema**. Quando algo depender dele, este manual avisa. As situações mais comuns estão na [seção 5](#5-quando-falar-com-o-administrador). + +--- + +## Sumário + +1. [Conceitos](#1-conceitos) +2. [Área do organizador](#2-área-do-organizador) + - 2.1 [Entrar](#21-entrar) · 2.2 [Painel de eventos](#22-painel-de-eventos) · 2.3 [Criar um evento](#23-criar-um-evento) + - 2.4 [Criar um convite](#24-criar-um-convite) · 2.5 [Enviar o convite](#25-enviar-o-convite) · 2.5.1 [Personalizar a mensagem do WhatsApp](#251-personalizar-a-mensagem-do-whatsapp) + - 2.6 [Acompanhar as respostas](#26-acompanhar-as-respostas) · 2.7 [Editar um convite](#27-editar-um-convite) · 2.8 [Zerar as respostas](#28-zerar-as-respostas-de-um-convite) + - 2.9 [Excluir um convite](#29-excluir-um-convite) · 2.10 [Editar ou excluir um evento](#210-editar-ou-excluir-um-evento) · 2.11 [PDFs dos convites](#211-pdfs-dos-convites) + - 2.12 [Prazo de confirmação](#212-prazo-de-confirmação-como-funciona) +3. [Experiência do convidado](#3-experiência-do-convidado) +4. [Perguntas frequentes](#4-perguntas-frequentes) +5. [Quando falar com o administrador](#5-quando-falar-com-o-administrador) + +--- + +## 1. Conceitos + +| Termo | Significado | +| --- | --- | +| **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**, **Adolescente** ou **Criança** | +| **Tema** | Visual do convite (cores e ícones), escolhido por evento. Há temas infantis e temas para adultos e adolescentes | +| **Prazo de confirmação** | Data opcional do evento. Depois dela, quem não respondeu conta como ausente e o convite não aceita mais respostas | +| **Código do convite** | Sequência única no final do link (ex.: `.../r/Ab3xY9kL`). Funciona como a "chave" do convite | +| **RSVP / Status** | Resposta de cada convidado: ✓ **Presente**, ✗ **Ausente** ou ⏳ **Pendente** | + +Um evento pode ter vários convites; cada convite pode ter vários convidados. + +--- + +## 2. Área do organizador + +### 2.1 Entrar + +1. Acesse o endereço da área do organizador informado pelo administrador (ex.: `https:///admin`). Também há o link discreto **"Área do organizador"** no rodapé da página inicial. +2. Digite a senha do organizador, também fornecida pelo administrador, e clique em **Entrar**. + +A sessão dura **7 dias** no mesmo navegador. No topo da página: + +- **Sair** encerra a sessão neste navegador; +- **Sair de todos** encerra a sessão em todos os dispositivos (use se esqueceu o admin aberto em outro computador ou celular). + +> 🔒 Depois de **5 senhas erradas em 15 minutos**, o login fica bloqueado por 15 minutos naquele endereço de internet. A mensagem na tela mostra quanto tempo falta. + +**Mensagens da tela de login:** + +| Mensagem | Significado | +| --- | --- | +| **Senha incorreta.** | A senha digitada não é a senha do organizador. Confira com o administrador | +| **Muitas tentativas incorretas. Tente novamente em N minutos.** | Bloqueio temporário após 5 erros. Durante o bloqueio, nem a senha certa é aceita | + +**Sessão vencida:** depois de 7 dias, ou após **Sair de todos**, qualquer página do organizador volta para a tela de login. Basta entrar de novo. + +**Página inicial do site:** é uma página neutra. Ela não lista eventos nem convites: só diz "Recebeu um convite? Abra o link que foi enviado para você" e tem o link discreto **Área do organizador**. Cada convite só é aberto pelo próprio link. + +### 2.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/adolescentes/crianças), **Ausentes** e **Pendentes**. + +Clique em um evento para abrir os detalhes. + +**Detalhes do painel:** + +- **Ordem:** os eventos aparecem do de **data mais distante para o de data mais próxima** (os mais recentes ou futuros no topo). +- **Prazo de confirmação:** quando o evento tem prazo, a linha de informações mostra ⏰ **"até dd/mm/aaaa"** enquanto o prazo está aberto, ou 🔒 **"encerrado"** depois dele. +- **Divisão por idade:** a linha "N adultos · N adolescentes · N crianças" só aparece quando há pelo menos um presente. +- **Depois do prazo:** quem não respondeu passa a contar como **Ausente** nos totais (ver 2.12). +- **Sem eventos:** aparece "Nenhum evento cadastrado ainda." e o botão **+ Novo evento**. + +### 2.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" + - **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 + - **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. + +**Regras dos campos:** + +| Campo | Regra | +| --- | --- | +| Nome, Data, Horário, Local, Endereço | Obrigatórios | +| Prazo para confirmar | Opcional. Não pode ser **depois** da data da festa. Vale **até o fim do dia** informado, no horário de Brasília | +| Texto do selo | Opcional, até **40** caracteres. Em branco, usa o selo do tema (tabela abaixo) | +| Texto do rodapé | Opcional, até **120** caracteres. Em branco, usa **"Feito com carinho · "** | +| Mensagem do WhatsApp | Opcional, até **950** caracteres, contando o texto final já com as variáveis trocadas. O link do WhatsApp gerado com ela vai até **2300** caracteres. Aceita variáveis e a formatação do WhatsApp (ver 2.5.1) | + +A prévia abaixo do campo **Tema** mostra, em tempo real: + +- o selo com o ícone do tema; +- um título de exemplo ("Família Exemplo"); +- o ícone separador; +- as seis cores da paleta; +- o rodapé com o texto digitado. + +**Temas disponíveis:** + +| Grupo | Tema | Selo padrão | Ícones (selo · separador) | +| --- | --- | --- | --- | +| Festas infantis | Safári | Safari Party | 🦁 · 🌿 | +| Festas infantis | Princesas | Reino Encantado | 👑 · ✨ | +| Festas infantis | Super-heróis | Super-Heróis | 🦸 · ⚡ | +| Festas infantis | Fundo do mar | Fundo do Mar | 🐳 · 🐚 | +| Festas infantis | Dinossauros | Dino Party | 🦖 · 🦕 | +| Festas infantis | Unicórnio | Mundo Mágico | 🦄 · 🌈 | +| Festas infantis | Astronauta | Missão Espacial | 🚀 · ⭐ | +| Festas infantis | Fazendinha | Fazendinha | 🐄 · 🌻 | +| Festas infantis | Futebol | Bola na Rede | ⚽ · 🏆 | +| Adultos e adolescentes | Clássico dourado | Celebração | 🥂 · ✨ | +| Adultos e adolescentes | Azul noturno | Noite de Festa | 🎉 · ✨ | +| Adultos e adolescentes | Rosé floral | Celebração | 💐 · 🌸 | +| Adultos e adolescentes | Boho verde sálvia | Encontro Especial | 🌿 · 🍃 | +| Adultos e adolescentes | Minimalista preto e branco | Vamos Celebrar | 🎈 · ✦ | +| Adultos e adolescentes | Balada neon | Bora Curtir | 🎧 · 🎶 | + +O ícone do selo também é usado na mensagem pronta do botão **WhatsApp** (variável `{icone}`) e no card de pré-visualização. + +**Mensagens de erro do formulário:** + +| Mensagem | Causa | +| --- | --- | +| **Preencha todos os campos.** | Falta nome, data, horário, local ou endereço | +| **Data ou horário inválido.** | Data ou horário em formato não reconhecido | +| **Tema inválido.** | Tema inexistente (não acontece pelo formulário normal) | +| **Textos do convite muito longos (selo até 40, rodapé até 120 caracteres).** | Selo ou rodapé acima do limite | +| **Mensagem do WhatsApp muito longa (até 950 caracteres).** | O texto digitado passa do limite | +| **Com as variáveis trocadas, a mensagem do WhatsApp fica com N caracteres (máximo 950). Encurte a mensagem.** | O texto cabe no limite, mas passa dele depois de trocar as variáveis pelos dados do evento e dos convites (ex.: endereço ou nome de família longos) | +| **Com as variáveis trocadas, o link do WhatsApp fica com N caracteres (máximo 2300): acentos e emojis ocupam mais espaço no link. Encurte a mensagem.** | O texto tem até 950 caracteres, mas muitos acentos ou emojis deixam o link longo demais | +| **Prazo de confirmação inválido.** | Data do prazo em formato não reconhecido | +| **O prazo de confirmação não pode ser depois da festa.** | Prazo posterior à data do evento | + +### 2.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**, **Adolescente** ou **Criança**; + - use **+ Adulto** / **+ Adolescente** / **+ 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. + +**Detalhes:** + +- **Mínimo de um convidado:** o botão **✕** fica desativado quando sobra só uma linha. +- **Link:** termina com um código de 8 caracteres aleatórios (letras, números, `-` e `_`), ex.: `/r/Ab3xY9kL`. Ele aparece em cada card como **"Link: /r/Ab3xY9kL"**. +- **Ordem na página do evento:** os convites aparecem em **ordem alfabética** do nome exibido. + +**Mensagens de erro do formulário (criar e editar):** + +| Mensagem | Causa | +| --- | --- | +| **Informe o nome exibido no convite.** | Campo "Nome exibido no convite" vazio | +| **Adicione pelo menos um convidado.** | Todas as linhas estão com o nome em branco | +| **Tipo de convidado inválido.** / **Lista de convidados inválida.** | Dados corrompidos no envio (não acontece pelo uso normal). Recarregue a página | +| **Convite não encontrado.** | O convite foi excluído enquanto era editado | +| **Com este nome, a mensagem do WhatsApp fica com N caracteres (máximo 950). Encurte o nome ou a mensagem do evento.** | O evento tem mensagem do WhatsApp personalizada e, com este nome no lugar de `{nome}`, ela passa do limite | +| **Com este nome, o link do WhatsApp fica com N caracteres (máximo 2300): acentos e emojis ocupam mais espaço no link. Encurte o nome ou a mensagem do evento.** | Mesmo caso, mas o que passa do limite é o link (ver 2.5.1) | + +### 2.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. +- **Copiar mensagem** — copia a mesma mensagem do botão **WhatsApp**, já com o nome da família, a formatação e o link. Use para enviar por outro caminho: e-mail, SMS, Telegram, WhatsApp Web, um grupo ou uma conversa já aberta. O botão mostra "✓ Mensagem copiada"; depois é só colar. +- **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. O texto pode ser personalizado por evento (ver 2.5.1). +- **Abrir convite** — abre o convite em outra aba, exatamente como o convidado verá. + +Ao colar o link no WhatsApp, aparece um **card de pré-visualização** com o tema, o nome da família, o nome da festa, a data e o local. O título do card é "*Família Silva*, chegou o seu convite!". Se o card aparecer sem imagem, veja a [seção 5](#5-quando-falar-com-o-administrador). + +> 🔒 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. + +#### 2.5.1 Personalizar a mensagem do WhatsApp + +A mensagem do botão **WhatsApp** é definida **por evento**, no campo **Mensagem do WhatsApp** de **+ Novo evento** ou **Editar evento**. Vale para todos os convites daquele evento. Cada convite troca as variáveis pelos próprios dados. + +> Não existe mensagem diferente por convite. Para escrever algo especial para uma família, clique em **WhatsApp** e **edite o texto no próprio WhatsApp** antes de enviar. A alteração vale só para aquele envio. + +**Mensagem padrão** (usada quando o campo fica em branco): + +``` +Olá, {nome}! Chegou o seu convite para: *{evento}* {icone} +Confirme a presença: {link} +``` + +**Variáveis** — escreva entre chaves, ou clique no botão da variável abaixo do campo para inseri-la na posição do cursor: + +| Variável | Vira | Exemplo | +| --- | --- | --- | +| `{nome}` | Nome exibido no convite | Família Silva | +| `{evento}` | Nome da festa | 1º Aniversário do Arthur | +| `{data}` | Data da festa | 15/11/2026 | +| `{hora}` | Horário | 16:00 | +| `{local}` | Local | Espaço Festa | +| `{endereco}` | Endereço (sem cedilha no nome da variável) | Rua das Flores, 100 - Belo Horizonte/MG | +| `{icone}` | Ícone do tema | 🦁 | +| `{link}` | Link do convite daquela família | https://seu-dominio/r/Ab3xY9kL | + +**Formatação do WhatsApp** — funciona normalmente, porque o texto chega ao WhatsApp exatamente como foi digitado: + +| Digite | Aparece como | +| --- | --- | +| `*texto*` | **negrito** | +| `_texto_` | *itálico* | +| `~texto~` | ~~riscado~~ | +| ```` ```texto``` ```` | `monoespaçado` (pode ocupar várias linhas) | +| `` `texto` `` | `código`, monoespaçado num trecho da linha | + +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:** + +``` +Olá, *{nome}*! 🎉 +Chegou o seu convite para: _{evento}_ {icone} + +📅 {data} às {hora} +📍 {local} — {endereco} + +Confirme a presença: {link} +``` + +**Pré-visualização:** abaixo do campo há um balão no estilo do WhatsApp que mostra a mensagem já formatada, atualizada enquanto você digita. Nome da festa, data, horário, local, endereço e ícone vêm do próprio formulário. Nome do convite e link usam valores de exemplo ("Família Exemplo" e um link fictício do mesmo tamanho de um link real). + +> Em navegadores muito antigos (ex.: Safari anterior à versão 16.4), a pré-visualização aparece **sem formatação**: o texto fica com os `*` e `_` à mostra. O envio pelo WhatsApp não é afetado. + +**Regras:** + +- **Link garantido:** se a mensagem não tiver `{link}`, o link do convite é adicionado em uma linha no final. +- **Limite de 950 caracteres**, que vale duas vezes: + - para o **texto digitado**; + - para o **texto final**, já com as variáveis trocadas. + +- **Limite de 2300 caracteres no link do WhatsApp.** Para abrir o WhatsApp, a mensagem vira um link. Nesse link, cada letra acentuada ocupa **6** caracteres e cada emoji até **12**. Por isso, uma mensagem com menos de 950 caracteres, mas cheia de acentos e emojis, ainda pode passar do limite. + +As duas margens evitam que o WhatsApp recuse mensagens longas em alguns aparelhos. Abaixo do campo aparecem três contadores: +- **Modelo:** o texto digitado; +- **Texto final (exemplo):** a mensagem com as variáveis trocadas; +- **Link do WhatsApp (exemplo):** o tamanho do link gerado. + +Os contadores ficam vermelhos quando algum limite é ultrapassado. O sistema confere os dois limites de novo: +- ao salvar o evento, com os nomes reais de todos os convites; +- ao criar ou editar um convite. +- **Variável escrita errada** (ex.: `{nomee}`) não é trocada: vai para o WhatsApp do jeito que foi digitada. Confira na pré-visualização. +- **Mudanças valem na hora** para os próximos cliques no botão **WhatsApp**. Mensagens já enviadas não mudam. +- A mensagem do botão é diferente do **card de pré-visualização** do link (imagem, título e descrição), que não é afetado por este campo. + +### 2.6 Acompanhar as respostas + +Na página do evento: + +- O **Resumo de presença** mostra o total de presentes (adultos, adolescentes 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. + +**Cabeçalho da página do evento:** abaixo do nome aparecem: + +- data, horário, local e endereço; +- o **tema** escolhido (ícone e nome); +- a situação do prazo, quando houver: + - ⏰ **"Confirmações até dd/mm/aaaa"** enquanto o prazo está aberto; + - 🔒 **"Confirmações encerradas em dd/mm/aaaa — pendentes contam como ausentes"**, em vermelho, depois dele. + +**Exportar a lista:** abaixo do Resumo de presença, em **Exportar lista (CSV)**, escolha **Todos**, **Presentes**, **Ausentes** ou **Pendentes**. O arquivo abre direto no Excel ou no Google Planilhas, com as colunas Convite, Convidado, Tipo, Status e Respondido em. Útil para mandar a lista para o buffet ou para a portaria. + +| 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 | + +Detalhes do arquivo: + +- **Colunas:** + - **Tipo** sai como Adulto, Adolescente ou Criança; + - **Respondido em** usa o horário de Brasília. +- **Formato:** separador `;` e UTF-8. Por isso o arquivo abre direto no **Excel em português**, com colunas e acentos corretos, e também no Google Planilhas. +- **Nome do arquivo:** junta o nome da festa e o filtro, ex.: `1-aniversario-do-arthur-presentes.csv`. +- **Proteção contra fórmulas:** nomes que começam com `=`, `+`, `-` ou `@` recebem um apóstrofo na frente, para o Excel não tratá-los como fórmula. +- **Acesso:** só funciona com o organizador logado. Sem login, o endereço do arquivo leva para a tela de login. +- **Depois do prazo de confirmação:** quem não respondeu sai como **Ausente** (ver 2.12). + +### 2.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. + +### 2.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. + +### 2.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.** + +### 2.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, além do PDF modelo e dos PDFs gerados. Não é possível desfazer. + +### 2.11 PDFs dos convites + +Cada evento pode ter um **PDF modelo** do convite. A partir dele o sistema gera um PDF por família, com o link do convite daquela família no lugar do link do modelo. Todo o resto do PDF (layout, imagens, textos, fontes, páginas e outros links) fica igual. + +**Requisito do modelo:** o PDF precisa ter um **link clicável** para um convite, no mesmo formato dos links do sistema (ex.: `https://convite.n1b3.com.br/r/_3lXq-MW`). É esse link que é trocado. Um endereço escrito como texto, sem ser clicável, ou um QR code não são trocados. + +**Enviar ou substituir o modelo** + +1. Na página do evento, na seção **PDF do convite**, escolha o arquivo `.pdf` (até 10 MB). +2. Clique em **Enviar modelo** (ou **Substituir modelo**, se já houver um). +3. O sistema confere se o PDF tem o link do convite. Se não tiver, o envio é recusado com uma mensagem. + +A seção mostra o nome do modelo atual, a data de atualização e a versão. Use **Baixar modelo** para conferir o arquivo. + +**Gerar os PDFs** + +- **Um convite:** no card do convite, clique em **Gerar PDF** (ou **Gerar novamente**). O botão mostra "Gerando PDF..." até terminar. +- **Todos:** clique em **Gerar PDFs de todos os convites**. A barra mostra o progresso ("23 de 58 concluídos"). A geração continua no servidor; você pode seguir usando a página ou até fechá-la. +- **Só o que falta:** **Gerar só pendentes e com erro** gera apenas os convites não gerados, com erro ou feitos com um modelo antigo. + +**Status do PDF em cada convite** + +| Status | Significado | +| --- | --- | +| **Não gerado** | Ainda não há PDF para este convite | +| **Gerando PDF...** | Em geração | +| **Gerado** | PDF pronto e feito com o modelo atual. Use **Baixar PDF** | +| **Modelo atualizado — gerar novamente** | O PDF foi feito com um modelo anterior. Gere de novo | +| **Erro ao gerar** | A geração falhou; a mensagem aparece abaixo. Clique em **Gerar novamente** | + +Quando um convite tem um PDF antigo (modelo anterior ou última tentativa com erro), o botão **Baixar PDF anterior** baixa a última versão gerada com sucesso. + +**Download:** o arquivo vem com o nome da família, ex.: `convite-familia-silva.pdf`. Só o organizador logado consegue baixar. + +> Ao substituir o modelo, os PDFs já gerados **não** são refeitos sozinhos: eles passam a aparecer como "Modelo atualizado — gerar novamente". Enquanto uma geração em lote está em andamento, não é possível trocar o modelo. + +**Detalhes da tela** + +- **Contadores:** abaixo do modelo aparece o resumo do evento, ex.: "12 gerados · 3 com modelo antigo · 1 com erro · 4 não gerados". +- **Confirmação:** se algum convite já tem PDF, **Gerar PDFs de todos os convites** pede confirmação antes de gerar tudo de novo. +- **Quando o botão "Gerar só pendentes e com erro" aparece:** só quando **parte** dos convites precisa ser gerada. Se nenhum precisa, ele some. Se todos precisam, use o botão de todos. +- **Resultado do último lote:** depois que o lote termina, a seção mostra "Última geração em lote: 55 de 58 concluídos · 3 com erro" (e "interrompida", se for o caso). +- **Lista de falhas:** a seção também mostra os nomes dos convites com erro ("Com erro: Família Souza, Família Lima"), para gerar de novo só esses. +- **Data da geração:** ao lado do status **Gerado** aparece quando o PDF foi feito. +- **Sem modelo:** o botão **Gerar PDF** dos convites fica desativado. Passando o mouse, aparece "Envie o PDF modelo do evento primeiro". +- **Validação antes do envio:** um arquivo que não termina em `.pdf` ou passa de 10 MB é recusado na hora, sem ser enviado. +- **Dois envios ao mesmo tempo:** se o modelo for trocado em outra aba ou dispositivo durante o seu envio, aparece "O modelo foi alterado em outra sessão. Recarregue a página e tente de novo." +- **Sessão expirada:** se a sessão vencer enquanto a página acompanha uma geração, aparece "Sua sessão expirou. Recarregue a página e entre novamente." A geração continua no servidor normalmente. +- **Fechar a página:** a geração em lote não depende da página aberta. Ao voltar, a seção mostra o andamento atual. +- **Nomes dos arquivos baixados:** + - PDF do convite: `convite-.pdf`, ex.: `convite-familia-silva.pdf`; + - modelo: `modelo-.pdf`. + +### 2.12 Prazo de confirmação: como funciona + +O prazo é opcional e definido no evento (2.3). Ele afeta o organizador e os convidados assim: + +| | Antes do prazo (até 23:59 do dia, horário de Brasília) | Depois do prazo | +| --- | --- | --- | +| **Convidado** | Vê "⏰ Confirme até dd/mm/aaaa" e responde normalmente | Vê "🔒 O prazo para confirmar presença terminou em dd/mm/aaaa. O convite não aceita mais alterações." Os botões ✓/✗ e **Confirmar Presença** somem | +| **Pendentes** | Aparecem como **Pendente** | Passam a aparecer como **Ausente**: no convite, no painel, na página do evento, na edição do convite e no CSV | +| **Organizador** | ⏰ "Confirmações até ..." | 🔒 "Confirmações encerradas em ..." | + +Pontos importantes: + +- **Nada é apagado.** Para o sistema, quem não respondeu continua como pendente; só a **exibição** muda. + - Se você **remover ou adiar o prazo** em **Editar evento**, essas pessoas voltam a aparecer como pendentes e o convite volta a aceitar respostas. +- **Prazo vencido com a página aberta:** se o convidado deixar a página aberta, o prazo passar e ele tentar enviar, a resposta é recusada. A página passa para o modo encerrado e mostra os pendentes como ausentes. +- **Zerar respostas** depois do prazo volta todos para pendente no sistema, mas eles continuam aparecendo como ausentes enquanto o prazo estiver encerrado. + +--- + +## 3. Experiência do convidado + +O convidado **não precisa de cadastro nem senha** — só do link recebido. + +### 3.1 Abrir o convite + +Ao abrir o link, o convidado vê: + +1. O **nome do convite** em destaque (ex.: "Família Silva — estamos esperando por você(s)!"). +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. + +**Visual do convite:** + +- **Topo:** o **selo** do evento, com o ícone do tema e o texto do selo (ou o padrão do tema). Abaixo, o nome do convite em letras grandes, a frase "estamos esperando por você(s)!" e o ícone separador do tema. +- **Detalhes da Festa:** quatro cartões (Data, Horário, Local e Endereço). +- **Convidados:** cada pessoa com o ícone do tipo (👤 Adulto, 🧑 Adolescente, 👶 Criança), o nome, o tipo e o status atual. No título, a contagem ("3 convidados"). +- **Rodapé:** os três ícones do tema, a frase "Convite digital personalizado" e o texto do rodapé do evento (ou "Feito com carinho · "). +- **Cores:** todo o convite usa as cores do tema. No celular, a barra do navegador também fica na cor do tema. +- **Carregamento:** enquanto o convite abre, aparece "Carregando convite...". + +### 3.2 Responder + +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**. + +Não é obrigatório responder por todos de uma vez: quem ficar como pendente pode ser respondido depois. + +**Detalhes da resposta:** + +- **Não dá para desmarcar:** o botão já marcado fica travado. Para mudar a resposta de uma pessoa, toque no **outro** botão (de ✓ para ✗ ou o contrário). Não há como voltar alguém para **Pendente** pelo convite; só o organizador pode fazer isso, com **Zerar respostas** (2.8), que vale para o convite inteiro. +- **Botão desativado:** **Confirmar Presença** só é liberado quando **pelo menos uma pessoa** está marcada. Antes disso aparece "Selecione a presença dos convidados acima para ativar o botão". +- **Dicas na tela:** + - com todos pendentes, aparece "Toque em ✓ para confirmar ou ✗ para recusar"; + - com alguma seleção, aparece "Você pode alterar as seleções antes de enviar". +- **O que é enviado:** só as pessoas marcadas com ✓ ou ✗. Quem ainda está pendente não é enviado e continua pendente. +- **Durante o envio:** o botão mostra "Enviando..." e não aceita um segundo toque. +- **Contadores:** o quadro Presentes / Ausentes / Pendentes muda na hora, conforme as marcações, mesmo antes de enviar. +- **Tela de sucesso:** + - mostra ", obrigado pela resposta!"; + - mostra as listas **Presentes (N)** e **Ausentes (N)**; + - enquanto ela está aberta, a lista fica travada; depois de **Fechar**, dá para alterar e enviar de novo. +- **Para o organizador:** cada envio registra a data e hora da resposta. Ela aparece como "respondeu em ..." na página do evento e na coluna "Respondido em" do CSV. + +### 3.3 Mudar a resposta + +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. + +Isso vale **até o prazo de confirmação**, se o evento tiver um. Depois dele, o convite só mostra as respostas, sem permitir alteração (ver 2.12). + +### 3.4 Mensagens possíveis + +| Mensagem | Significado | +| --- | --- | +| **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 (ex.: o organizador removeu um convidado). Recarregue a página e responda de novo | +| **⏰ Confirme até dd/mm/aaaa** | O evento tem prazo e ele ainda está aberto | +| **🔒 O prazo para confirmar presença terminou em dd/mm/aaaa. O convite não aceita mais alterações.** | O prazo acabou. As respostas aparecem só para consulta e os pendentes aparecem como ausentes | +| **Selecione a presença dos convidados acima para ativar o botão** | Ninguém foi marcado ainda; o botão **Confirmar Presença** está desativado | + +--- + +## 4. Perguntas frequentes + +**O convidado precisa instalar algum aplicativo?** +Não. Funciona no navegador do celular ou do computador. + +**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. + +**Duas pessoas da mesma família podem responder pelo mesmo link?** +Sim. Vale a última resposta enviada para cada convidado. + +**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. + +**O prazo acabou, mas uma família pediu para responder. Dá?** +Sim. Em **Editar evento**, adie ou apague o prazo. O convite volta a aceitar respostas na hora, e quem não tinha respondido volta a aparecer como pendente. + +**Posso mudar o tema depois de enviar os convites?** +Sim. Em **Editar evento**, troque o tema; todos os convites do evento mudam na hora. Só o card do WhatsApp de links já compartilhados pode continuar com a imagem antiga por um tempo (o WhatsApp guarda o card). + +**Dá para mudar o texto que o botão WhatsApp envia?** +Sim. Em **Editar evento**, preencha **Mensagem do WhatsApp**. Use variáveis como `{nome}` e `{link}` e a formatação do WhatsApp (`*negrito*`, `_itálico_`). Ver 2.5.1. + +**Enviei o link errado para uma família. Como invalido?** +Exclua o convite e crie outro — o novo convite terá um link diferente. + +**Esqueci a senha do organizador.** +Peça ao administrador do sistema. Ele consegue consultar ou trocar a senha. + +**Os dados ficam salvos se o servidor reiniciar?** +Sim. Eventos, convites, respostas e PDFs ficam guardados no servidor. O administrador também faz cópias de segurança. + +**O PDF de um convite mostra o link de outra família ou um link antigo.** +Gere o PDF daquele convite de novo. Se o endereço do link estiver errado em **todos** os PDFs, avise o administrador: esse endereço é configurado no servidor. + +**O servidor reiniciou no meio da geração em lote. Perdi algo?** +Não. Os PDFs já gerados continuam valendo; os que estavam na fila aparecem com erro. Clique em **Gerar só pendentes e com erro**. + + +--- + +## 5. Quando falar com o administrador + +Algumas situações dependem do servidor e só o administrador do sistema resolve. Outras você mesmo resolve. Use a tabela para saber o que fazer. + +| Situação | O que fazer | +| --- | --- | +| Esqueci a senha do organizador | Peça a senha ao administrador | +| **"Muitas tentativas incorretas. Tente novamente em N minutos."** | Espere o tempo indicado. Se for urgente, o administrador pode liberar o acesso antes | +| O site não abre (nem a área do organizador nem os convites) | Avise o administrador | +| Link colado no WhatsApp aparece **sem imagem** | Teste com um convite que ainda não foi compartilhado: o WhatsApp guarda o card antigo de cada link por um tempo. Se o novo também vier sem imagem, avise o administrador | +| Link copiado aparece com **números** (ex.: `http://192.168...`) em vez do endereço do site | Entre na área do organizador pelo endereço do site, e não pelo número, e copie o link de novo | +| **"Nenhum link de convite (…/r/…) foi encontrado neste PDF"** ao enviar o modelo | Confira se o PDF tem um **link clicável** para um convite (ver 2.11). Se tiver e o erro continuar, avise o administrador | +| **"Serviço de PDF indisponível"** ou **"Endereço do link não configurado"** | Avise o administrador | +| Convite com **"Erro ao gerar"** | Clique em **Gerar novamente**. Se o erro se repetir, avise o administrador e informe a mensagem que aparece no card | +| **"Geração interrompida pela reinicialização do servidor"** | Clique em **Gerar só pendentes e com erro** | +| O link dentro de **todos** os PDFs está errado | Avise o administrador. Depois que ele corrigir, gere os PDFs de novo | + +> Ao falar com o administrador, informe **a mensagem exata** que apareceu na tela e **o que você estava fazendo**: o evento, o convite e o botão clicado. Isso ajuda a encontrar o problema mais rápido. diff --git a/drizzle/0002_event_theme.sql b/drizzle/0002_event_theme.sql new file mode 100644 index 0000000..e21c922 --- /dev/null +++ b/drizzle/0002_event_theme.sql @@ -0,0 +1 @@ +ALTER TABLE "events" ADD COLUMN "theme" text DEFAULT 'safari' NOT NULL; \ No newline at end of file diff --git a/drizzle/0003_event_custom_texts.sql b/drizzle/0003_event_custom_texts.sql new file mode 100644 index 0000000..39f2acd --- /dev/null +++ b/drizzle/0003_event_custom_texts.sql @@ -0,0 +1,2 @@ +ALTER TABLE "events" ADD COLUMN "badge_text" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "footer_text" text; \ No newline at end of file diff --git a/drizzle/0004_organizer_sessions.sql b/drizzle/0004_organizer_sessions.sql new file mode 100644 index 0000000..54b5c2c --- /dev/null +++ b/drizzle/0004_organizer_sessions.sql @@ -0,0 +1,6 @@ +CREATE TABLE "organizer_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); diff --git a/drizzle/0005_pdf_generation.sql b/drizzle/0005_pdf_generation.sql new file mode 100644 index 0000000..0b13446 --- /dev/null +++ b/drizzle/0005_pdf_generation.sql @@ -0,0 +1,23 @@ +CREATE TYPE "public"."pdf_job_status" AS ENUM('running', 'finished', 'interrupted');--> statement-breakpoint +CREATE TYPE "public"."pdf_status" AS ENUM('not_generated', 'generating', 'generated', 'error');--> statement-breakpoint +CREATE TABLE "pdf_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_id" uuid NOT NULL, + "status" "pdf_job_status" DEFAULT 'running' NOT NULL, + "invitation_ids" jsonb NOT NULL, + "total" integer NOT NULL, + "processed" integer DEFAULT 0 NOT NULL, + "failed" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "finished_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "pdf_template_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "pdf_template_name" text;--> statement-breakpoint +ALTER TABLE "events" ADD COLUMN "pdf_template_updated_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "invitations" ADD COLUMN "pdf_status" "pdf_status" DEFAULT 'not_generated' NOT NULL;--> statement-breakpoint +ALTER TABLE "invitations" ADD COLUMN "pdf_template_version" integer;--> statement-breakpoint +ALTER TABLE "invitations" ADD COLUMN "pdf_generated_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "invitations" ADD COLUMN "pdf_error" text;--> statement-breakpoint +ALTER TABLE "pdf_jobs" ADD CONSTRAINT "pdf_jobs_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "pdf_jobs_one_running_per_event" ON "pdf_jobs" USING btree ("event_id") WHERE "pdf_jobs"."status" = 'running'; \ No newline at end of file diff --git a/drizzle/0006_event_whatsapp_message.sql b/drizzle/0006_event_whatsapp_message.sql new file mode 100644 index 0000000..478ceb8 --- /dev/null +++ b/drizzle/0006_event_whatsapp_message.sql @@ -0,0 +1 @@ +ALTER TABLE "events" ADD COLUMN "whatsapp_message" text; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..c9e1f4d --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,255 @@ +{ + "id": "15b3f01a-7588-4737-83ac-23d6959739d3", + "prevId": "e48d8802-8721-4b4e-9fa7-89dc71605f4a", + "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'" + }, + "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 + }, + "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 + } + }, + "enums": { + "public.guest_type": { + "name": "guest_type", + "schema": "public", + "values": [ + "adult", + "teen", + "child" + ] + }, + "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/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..bb9d508 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,267 @@ +{ + "id": "d3af7bf8-a073-41db-b56d-544331c42345", + "prevId": "15b3f01a-7588-4737-83ac-23d6959739d3", + "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 + }, + "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 + }, + "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 + } + }, + "enums": { + "public.guest_type": { + "name": "guest_type", + "schema": "public", + "values": [ + "adult", + "teen", + "child" + ] + }, + "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/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..07e040a --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,305 @@ +{ + "id": "04383ff0-b0ea-4d85-8289-a56bce363364", + "prevId": "d3af7bf8-a073-41db-b56d-544331c42345", + "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 + }, + "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 + }, + "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 + } + }, + "enums": { + "public.guest_type": { + "name": "guest_type", + "schema": "public", + "values": [ + "adult", + "teen", + "child" + ] + }, + "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/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..f179f3b --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,473 @@ +{ + "id": "0737d501-be94-4c28-8338-3ae445f2c85c", + "prevId": "04383ff0-b0ea-4d85-8289-a56bce363364", + "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 + }, + "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/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..5014988 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,479 @@ +{ + "id": "54db5cb2-3221-41c3-876d-2d4ed9fae526", + "prevId": "0737d501-be94-4c28-8338-3ae445f2c85c", + "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 + }, + "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/pdf-service/.dockerignore b/pdf-service/.dockerignore new file mode 100644 index 0000000..4ad112e --- /dev/null +++ b/pdf-service/.dockerignore @@ -0,0 +1,4 @@ +__pycache__ +*.pyc +.venv +*.md diff --git a/pdf-service/Dockerfile b/pdf-service/Dockerfile new file mode 100644 index 0000000..c7da245 --- /dev/null +++ b/pdf-service/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + STORAGE_DIR=/storage + +WORKDIR /app + +# Mesmo uid/gid (1001) do usuário do container do Next: os dois gravam no volume compartilhado +RUN groupadd -g 1001 pdf && useradd -u 1001 -g pdf -M -s /usr/sbin/nologin pdf \ + && mkdir -p /storage && chown pdf:pdf /storage + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +USER pdf +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/pdf-service/app/__init__.py b/pdf-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pdf-service/app/main.py b/pdf-service/app/main.py new file mode 100644 index 0000000..ff38cfe --- /dev/null +++ b/pdf-service/app/main.py @@ -0,0 +1,135 @@ +"""Serviço interno de PDFs dos convites. + +Recebe do Next.js apenas identificadores (evento, convite, versão do modelo) e o token do convite. +Os caminhos são montados aqui, dentro de STORAGE_DIR, com a mesma estrutura de src/lib/pdf/storage.ts: + + /events//template/modelo-v.pdf + /events//generated/.pdf + +O link novo é /r/; sem INVITE_LINK_BASE_URL, vale o APP_URL. +A personalização troca somente a URI dos links que contêm INVITE_LINK_PATTERN (ex.: "com.br/r/") +pelo link do convite; layout, imagens, textos, fontes e demais links ficam intactos. +""" + +import logging +import os +import tempfile +from pathlib import Path + +import fitz # PyMuPDF +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("pdf-service") + +STORAGE_DIR = Path(os.environ.get("STORAGE_DIR", "/storage")).resolve() +# Sem INVITE_LINK_BASE_URL, usa o endereço público do app (APP_URL) +LINK_BASE_URL = ( + os.environ.get("INVITE_LINK_BASE_URL", "").strip() or os.environ.get("APP_URL", "").strip() +).rstrip("/") +LINK_PATTERN = os.environ.get("INVITE_LINK_PATTERN", "com.br/r/").strip() + +UUID_RE = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +TOKEN_RE = r"^[A-Za-z0-9_-]{1,64}$" + +app = FastAPI(title="pdf-service", docs_url=None, redoc_url=None, openapi_url=None) + + +class TemplateRequest(BaseModel): + event_id: str = Field(pattern=UUID_RE) + template_version: int = Field(ge=1) + + +class GenerateRequest(TemplateRequest): + invitation_id: str = Field(pattern=UUID_RE) + token: str = Field(pattern=TOKEN_RE) + + +def _inside_storage(path: Path) -> Path: + # Defesa extra contra path traversal: os ids já são validados como UUID + resolved = path.resolve() + if STORAGE_DIR not in resolved.parents: + raise HTTPException(status_code=400, detail="Caminho inválido.") + return resolved + + +def _template_path(event_id: str, version: int) -> Path: + return _inside_storage(STORAGE_DIR / "events" / event_id.lower() / "template" / f"modelo-v{version}.pdf") + + +def _generated_path(event_id: str, invitation_id: str) -> Path: + return _inside_storage(STORAGE_DIR / "events" / event_id.lower() / "generated" / f"{invitation_id.lower()}.pdf") + + +def _is_invite_link(link: dict) -> bool: + return LINK_PATTERN in (link.get("uri") or "") + + +def _open_template(path: Path) -> fitz.Document: + if not path.is_file(): + raise HTTPException(status_code=404, detail="PDF modelo não encontrado no armazenamento.") + try: + return fitz.open(path) + except Exception: + log.exception("Falha ao abrir o modelo %s", path) + raise HTTPException(status_code=422, detail="Não foi possível ler o PDF modelo.") + + +@app.get("/health") +def health() -> dict: + return {"status": "ok", "storage": STORAGE_DIR.is_dir(), "link_base_url": bool(LINK_BASE_URL)} + + +@app.post("/templates/inspect") +def inspect_template(req: TemplateRequest) -> dict: + """Conta os links de convite do modelo (0 = modelo inválido para personalização).""" + doc = _open_template(_template_path(req.event_id, req.template_version)) + try: + links = sum(1 for page in doc for link in page.get_links() if _is_invite_link(link)) + finally: + doc.close() + log.info("Modelo evento=%s versão=%s: %s link(s) de convite", req.event_id, req.template_version, links) + return {"links": links} + + +@app.post("/invitations/generate") +def generate_invitation(req: GenerateRequest) -> dict: + if not LINK_BASE_URL: + raise HTTPException(status_code=500, detail="Endereço do link não configurado: defina APP_URL (ou INVITE_LINK_BASE_URL) no .env.") + + new_link = f"{LINK_BASE_URL}/r/{req.token}" + output = _generated_path(req.event_id, req.invitation_id) + doc = _open_template(_template_path(req.event_id, req.template_version)) + tmp_name = None + try: + replaced = 0 + for page in doc: + for link in page.get_links(): + if _is_invite_link(link): + link["uri"] = new_link + page.update_link(link) + replaced += 1 + + if replaced == 0: + raise HTTPException(status_code=422, detail="Link do convite não encontrado no PDF modelo.") + + # Grava em arquivo temporário e troca no fim: o PDF anterior só é substituído se tudo der certo + output.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=output.parent, suffix=".tmp") + os.close(fd) + doc.save(tmp_name) + os.replace(tmp_name, output) + tmp_name = None + except HTTPException: + raise + except Exception: + log.exception("Falha ao gerar o PDF do convite %s", req.invitation_id) + raise HTTPException(status_code=500, detail="Falha ao gerar o PDF do convite.") + finally: + doc.close() + if tmp_name and os.path.exists(tmp_name): + os.remove(tmp_name) + + log.info("PDF gerado convite=%s evento=%s links=%s", req.invitation_id, req.event_id, replaced) + return {"links": replaced} diff --git a/pdf-service/requirements.txt b/pdf-service/requirements.txt new file mode 100644 index 0000000..70fa56e --- /dev/null +++ b/pdf-service/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115,<1.0 +uvicorn>=0.32,<1.0 +pymupdf>=1.25,<2.0 diff --git a/src/app/admin/(painel)/eventos/[id]/convites/[conviteId]/pdf/route.ts b/src/app/admin/(painel)/eventos/[id]/convites/[conviteId]/pdf/route.ts new file mode 100644 index 0000000..7167159 --- /dev/null +++ b/src/app/admin/(painel)/eventos/[id]/convites/[conviteId]/pdf/route.ts @@ -0,0 +1,37 @@ +import { and, eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { db } from "@/db"; +import { invitations } from "@/db/schema"; +import { requireOrganizer } from "@/lib/auth"; +import { isUuid } from "@/lib/organizer"; +import { generatedPdfPath, openPdf } from "@/lib/pdf/storage"; +import { slugify } from "@/lib/slugify"; + +// GET /admin/eventos//convites//pdf — baixa o PDF personalizado do convite. +// O convite precisa pertencer ao evento da URL; o caminho do arquivo nunca vem do navegador. +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string; conviteId: string }> } +) { + await requireOrganizer(); + const { id, conviteId } = await params; + if (!isUuid(id) || !isUuid(conviteId)) notFound(); + + const invitation = await db.query.invitations.findFirst({ + where: and(eq(invitations.id, conviteId), eq(invitations.eventId, id)), + columns: { displayName: true, pdfGeneratedAt: true }, + }); + if (!invitation?.pdfGeneratedAt) notFound(); + + const file = await openPdf(generatedPdfPath(id, conviteId)); + if (!file) notFound(); + + return new Response(file.stream, { + headers: { + "Content-Type": "application/pdf", + "Content-Length": String(file.size), + "Content-Disposition": `attachment; filename="convite-${slugify(invitation.displayName, "familia")}.pdf"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/admin/(painel)/eventos/[id]/exportar/route.ts b/src/app/admin/(painel)/eventos/[id]/exportar/route.ts new file mode 100644 index 0000000..e82b475 --- /dev/null +++ b/src/app/admin/(painel)/eventos/[id]/exportar/route.ts @@ -0,0 +1,71 @@ +import { notFound } from "next/navigation"; +import type { NextRequest } from "next/server"; +import { requireOrganizer } from "@/lib/auth"; +import { getEventWithInvitations } from "@/lib/organizer"; +import { slugify } from "@/lib/slugify"; +import type { GuestType, RsvpStatus } from "@/types/invitation"; + +const statusLabels: Record = { + confirmed: "Presente", + declined: "Ausente", + pending: "Pendente", +}; + +const typeLabels: Record = { + adult: "Adulto", + teen: "Adolescente", + child: "Criança", +}; + +const respondedFormatter = new Intl.DateTimeFormat("pt-BR", { + dateStyle: "short", + timeStyle: "short", + timeZone: "America/Sao_Paulo", +}); + +function isStatus(value: string | null): value is RsvpStatus { + return value === "confirmed" || value === "declined" || value === "pending"; +} + +// Aspas sempre; valores que começam com = + - @ ganham um apóstrofo para o Excel +// não interpretá-los como fórmula (nomes vêm de digitação livre do organizador) +function cell(value: string): string { + const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; + return `"${safe.replace(/"/g, '""')}"`; +} + +// GET /admin/eventos//exportar?status=confirmed|declined|pending (sem status = todos) +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + await requireOrganizer(); + const { id } = await params; + const event = await getEventWithInvitations(id); + if (!event) notFound(); + + const statusParam = request.nextUrl.searchParams.get("status"); + const status = isStatus(statusParam) ? statusParam : undefined; + + const header = ["Convite", "Convidado", "Tipo", "Status", "Respondido em"]; + const rows = event.invitations.flatMap((inv) => + inv.guests + .filter((g) => !status || g.status === status) + .map((g) => [ + inv.displayName, + g.name, + typeLabels[g.type], + statusLabels[g.status], + g.respondedAt ? respondedFormatter.format(g.respondedAt) : "", + ]) + ); + + // ";" e BOM UTF-8: o Excel em português abre com colunas e acentos corretos + const csv = "" + [header, ...rows].map((r) => r.map(cell).join(";")).join("\r\n") + "\r\n"; + const fileName = `${slugify(event.name)}-${status ? slugify(statusLabels[status] + "s") : "convidados"}.csv`; + + return new Response(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${fileName}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/admin/(painel)/eventos/[id]/pdf/modelo/route.ts b/src/app/admin/(painel)/eventos/[id]/pdf/modelo/route.ts new file mode 100644 index 0000000..2688076 --- /dev/null +++ b/src/app/admin/(painel)/eventos/[id]/pdf/modelo/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 { requireOrganizer } from "@/lib/auth"; +import { isUuid } from "@/lib/organizer"; +import { openPdf, templatePath } from "@/lib/pdf/storage"; +import { slugify } from "@/lib/slugify"; + +// GET /admin/eventos//pdf/modelo — baixa o PDF modelo atual 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: { name: true, pdfTemplateVersion: true }, + }); + if (!event || event.pdfTemplateVersion < 1) notFound(); + + const file = await openPdf(templatePath(id, event.pdfTemplateVersion)); + if (!file) notFound(); + + return new Response(file.stream, { + headers: { + "Content-Type": "application/pdf", + "Content-Length": String(file.size), + "Content-Disposition": `attachment; filename="modelo-${slugify(event.name)}.pdf"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/admin/(painel)/eventos/[id]/pdf/status/route.ts b/src/app/admin/(painel)/eventos/[id]/pdf/status/route.ts new file mode 100644 index 0000000..f8dac82 --- /dev/null +++ b/src/app/admin/(painel)/eventos/[id]/pdf/status/route.ts @@ -0,0 +1,15 @@ +import { isOrganizer } from "@/lib/auth"; +import { getEventPdfState } from "@/lib/pdf/state"; + +// GET /admin/eventos//pdf/status — modelo, lote em andamento e status do PDF de cada convite. +// Consultado periodicamente pela tela do evento enquanto há PDFs sendo gerados. +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + if (!(await isOrganizer())) { + return Response.json({ error: "Sessão expirada. Entre novamente." }, { status: 401 }); + } + const { id } = await params; + const state = await getEventPdfState(id); + if (!state) return Response.json({ error: "Evento não encontrado." }, { status: 404 }); + + return Response.json(state, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/src/app/admin/pdf-actions.ts b/src/app/admin/pdf-actions.ts new file mode 100644 index 0000000..7257548 --- /dev/null +++ b/src/app/admin/pdf-actions.ts @@ -0,0 +1,122 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { events, pdfJobs } from "@/db/schema"; +import type { FormState } from "@/app/admin/actions"; +import { requireOrganizer } from "@/lib/auth"; +import { isUuid } from "@/lib/organizer"; +import { type BatchMode, claimInvitation, createPdfJob, enqueuePdfJob, generateForInvitation } from "@/lib/pdf/jobs"; +import { inspectTemplate, PdfServiceError } from "@/lib/pdf/service"; +import { removeOtherTemplates, removeTemplate, saveTemplate } from "@/lib/pdf/storage"; +import { PDF_TEMPLATE_MAX_MB, type PdfActionResult } from "@/lib/pdf/types"; + +const MAX_BYTES = PDF_TEMPLATE_MAX_MB * 1024 * 1024; + +// Nome original só para exibição: sem pastas, sem caracteres de controle, tamanho limitado +function displayFileName(name: string): string { + const base = name.split(/[\\/]/).pop() ?? ""; + const clean = base.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 200); + return clean || "convite-modelo.pdf"; +} + +// O tipo informado pelo navegador não é confiável: confere a assinatura "%PDF-" no início do arquivo +function looksLikePdf(content: Uint8Array): boolean { + const head = Buffer.from(content.subarray(0, 1024)).toString("latin1"); + return head.includes("%PDF-"); +} + +async function hasRunningJob(eventId: string): Promise { + const job = await db.query.pdfJobs.findFirst({ + where: and(eq(pdfJobs.eventId, eventId), eq(pdfJobs.status, "running")), + columns: { id: true }, + }); + return job !== undefined; +} + +// ---------- PDF modelo ---------- + +export async function uploadPdfTemplate(eventId: string, _prev: FormState, formData: FormData): Promise { + await requireOrganizer(); + if (!isUuid(eventId)) return { error: "Evento inválido." }; + + const event = await db.query.events.findFirst({ + where: eq(events.id, eventId), + columns: { id: true, pdfTemplateVersion: true }, + }); + if (!event) return { error: "Evento não encontrado." }; + if (await hasRunningJob(eventId)) { + return { error: "Aguarde a geração em andamento terminar para substituir o modelo." }; + } + + const file = formData.get("template"); + if (!(file instanceof File) || file.size === 0) return { error: "Selecione um arquivo PDF." }; + if (file.size > MAX_BYTES) return { error: `O arquivo deve ter no máximo ${PDF_TEMPLATE_MAX_MB} MB.` }; + if (!file.name.toLowerCase().endsWith(".pdf")) return { error: "Envie um arquivo .pdf." }; + + const content = new Uint8Array(await file.arrayBuffer()); + if (!looksLikePdf(content)) return { error: "O arquivo enviado não é um PDF válido." }; + + const version = event.pdfTemplateVersion + 1; + await saveTemplate(eventId, version, content); + + // O modelo precisa ter o link do convite, senão nenhum PDF poderia ser gerado + try { + const { links } = await inspectTemplate({ eventId, templateVersion: version }); + if (links === 0) { + await removeTemplate(eventId, version); + return { error: "Nenhum link de convite (…/r/…) foi encontrado neste PDF. Confira o modelo." }; + } + } catch (err) { + await removeTemplate(eventId, version); + const message = err instanceof PdfServiceError ? err.message : "Erro inesperado ao validar o PDF."; + if (!(err instanceof PdfServiceError)) console.error("Falha ao validar o PDF modelo:", err); + return { error: message }; + } + + // Condição na versão: dois uploads simultâneos não sobrescrevem um ao outro sem aviso + const updated = await db + .update(events) + .set({ pdfTemplateVersion: version, pdfTemplateName: displayFileName(file.name), pdfTemplateUpdatedAt: new Date() }) + .where(and(eq(events.id, eventId), eq(events.pdfTemplateVersion, event.pdfTemplateVersion))) + .returning({ id: events.id }); + if (updated.length === 0) { + return { error: "O modelo foi alterado em outra sessão. Recarregue a página e tente de novo." }; + } + + await removeOtherTemplates(eventId, version).catch((e) => console.error("Falha ao remover modelos antigos:", e)); + revalidatePath(`/admin/eventos/${eventId}`); + return {}; +} + +// ---------- PDFs personalizados ---------- + +export async function generateInvitationPdfAction(eventId: string, invitationId: string): Promise { + await requireOrganizer(); + if (!isUuid(eventId) || !isUuid(invitationId)) return { error: "Convite inválido." }; + + const event = await db.query.events.findFirst({ + where: eq(events.id, eventId), + columns: { pdfTemplateVersion: true }, + }); + if (!event) return { error: "Evento não encontrado." }; + if (event.pdfTemplateVersion < 1) return { error: "Envie o PDF modelo do evento antes de gerar os convites." }; + + if (!(await claimInvitation(eventId, invitationId))) { + return { error: "Este convite já está sendo gerado ou não pertence a este evento." }; + } + const result = await generateForInvitation(eventId, invitationId); + return result.ok ? {} : { error: result.error }; +} + +export async function startPdfBatch(eventId: string, mode: BatchMode): Promise { + await requireOrganizer(); + if (!isUuid(eventId)) return { error: "Evento inválido." }; + if (mode !== "all" && mode !== "pending") return { error: "Opção inválida." }; + + const result = await createPdfJob(eventId, mode); + if ("error" in result) return { error: result.error }; + enqueuePdfJob(result.jobId); + return {}; +} diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..68d8599 --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/app/r/[token]/opengraph-image.tsx b/src/app/r/[token]/opengraph-image.tsx new file mode 100644 index 0000000..f7807b7 --- /dev/null +++ b/src/app/r/[token]/opengraph-image.tsx @@ -0,0 +1,110 @@ +import { ImageResponse } from "next/og"; +import { connection } from "next/server"; +import { getInvitationByToken } from "@/lib/invitation"; +import { getTheme } from "@/lib/themes"; + +// Imagem do card quando o link do convite é colado no WhatsApp ou em redes sociais. +// Mostra só o que a própria página já mostra a quem tem o link; a lista de convidados fica de fora. +export const alt = "Convite de aniversário"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function Image({ params }: { params: Promise<{ token: string }> }) { + // Gera na hora: o organizador pode editar o evento depois de enviar o link + await connection(); + const { token } = await params; + const invitation = await getInvitationByToken(token); + const theme = getTheme(invitation?.theme); + const p = theme.palette; + + if (!invitation) { + return new ImageResponse( + ( +
+ Convite +
+ ), + size + ); + } + + const { event } = invitation; + return new ImageResponse( + ( +
+
+ {theme.heroIcon} + {invitation.badgeText ?? theme.badge} +
+
+ {/* Frase neutra: serve para família ou pessoa e para qualquer nome de festa */} + {`${invitation.displayName}, chegou o seu convite para`} +
+
32 ? 64 : 80, + lineHeight: 1.1, + color: p[900], + }} + > + {event.name} +
+
+ {`${event.date} · ${event.time} · ${event.venue}`} +
+
+ {`${theme.dividerIcon} Toque para confirmar sua presença ${theme.dividerIcon}`} +
+
+ ), + size + ); +} diff --git a/src/components/admin/EventPdf.tsx b/src/components/admin/EventPdf.tsx new file mode 100644 index 0000000..d32e820 --- /dev/null +++ b/src/components/admin/EventPdf.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { Download, FileText, Upload } from "lucide-react"; +import { createContext, useActionState, useCallback, useContext, useEffect, useRef, useState } from "react"; +import type { FormState } from "@/app/admin/actions"; +import { generateInvitationPdfAction, startPdfBatch } from "@/app/admin/pdf-actions"; +import { Button } from "@/components/ui"; +import { + type EventPdfState, + type InvitationPdfState, + PDF_TEMPLATE_MAX_MB, + type PdfViewStatus, +} from "@/lib/pdf/types"; + +const POLL_INTERVAL_MS = 1500; + +const statusLabels: Record = { + not_generated: "Não gerado", + generating: "Gerando PDF...", + generated: "Gerado", + outdated: "Modelo atualizado — gerar novamente", + error: "Erro ao gerar", +}; + +const statusStyles: Record = { + not_generated: "bg-slate-100 text-slate-700", + generating: "bg-indigo-100 text-indigo-800", + generated: "bg-emerald-100 text-emerald-800", + outdated: "bg-amber-100 text-amber-800", + error: "bg-rose-100 text-rose-800", +}; + +const dateFormatter = new Intl.DateTimeFormat("pt-BR", { + dateStyle: "short", + timeStyle: "short", + timeZone: "America/Sao_Paulo", +}); + +const smallButton = + "text-sm px-3 py-1.5 rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"; + +interface EventPdfContextValue { + eventId: string; + state: EventPdfState; + pendingIds: Set; + errors: Record; + generate: (invitationId: string) => Promise; + refresh: () => Promise; +} + +const EventPdfContext = createContext(null); + +function useEventPdf(): EventPdfContextValue { + const ctx = useContext(EventPdfContext); + if (!ctx) throw new Error("useEventPdf precisa estar dentro de ."); + return ctx; +} + +// Guarda o estado dos PDFs do evento e consulta a rota de status enquanto há geração em andamento, +// atualizando a tela sem recarregar a página +export function EventPdfProvider({ + eventId, + initialState, + children, +}: { + eventId: string; + initialState: EventPdfState; + children: React.ReactNode; +}) { + const [state, setState] = useState(initialState); + const [pendingIds, setPendingIds] = useState>(() => new Set()); + const [errors, setErrors] = useState>({}); + const [sessionError, setSessionError] = useState(false); + + const refresh = useCallback(async () => { + try { + const response = await fetch(`/admin/eventos/${eventId}/pdf/status`, { cache: "no-store" }); + if (response.status === 401) { + setSessionError(true); + return; + } + if (response.ok) setState((await response.json()) as EventPdfState); + } catch { + // Falha de rede momentânea: a próxima consulta tenta de novo + } + }, [eventId]); + + const working = + state.job?.status === "running" || + state.invitations.some((inv) => inv.status === "generating") || + pendingIds.size > 0; + + useEffect(() => { + if (!working || sessionError) return; + const timer = setInterval(refresh, POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [working, sessionError, refresh]); + + const generate = useCallback( + async (invitationId: string) => { + setPendingIds((prev) => new Set(prev).add(invitationId)); + setErrors((prev) => { + const next = { ...prev }; + delete next[invitationId]; + return next; + }); + try { + const result = await generateInvitationPdfAction(eventId, invitationId); + if (result.error) setErrors((prev) => ({ ...prev, [invitationId]: result.error! })); + } catch { + setErrors((prev) => ({ ...prev, [invitationId]: "Não foi possível gerar o PDF. Tente novamente." })); + } finally { + await refresh(); + setPendingIds((prev) => { + const next = new Set(prev); + next.delete(invitationId); + return next; + }); + } + }, + [eventId, refresh] + ); + + return ( + + {sessionError && ( +

+ Sua sessão expirou. Recarregue a página e entre novamente. +

+ )} + {children} +
+ ); +} + +function TemplateUploadForm({ + action, + hasTemplate, + disabled, +}: { + action: (prev: FormState, formData: FormData) => Promise; + hasTemplate: boolean; + disabled: boolean; +}) { + const [state, formAction, pending] = useActionState(action, {}); + const [clientError, setClientError] = useState(null); + const inputRef = useRef(null); + + // Confere antes de enviar: acima do limite o servidor recusaria o envio inteiro + const validate = (file: File | undefined): string | null => { + if (!file) return "Selecione um arquivo PDF."; + if (!file.name.toLowerCase().endsWith(".pdf")) return "Envie um arquivo .pdf."; + if (file.size > PDF_TEMPLATE_MAX_MB * 1024 * 1024) return `O arquivo deve ter no máximo ${PDF_TEMPLATE_MAX_MB} MB.`; + return null; + }; + + const error = clientError ?? state.error; + + return ( +
{ + const problem = validate(inputRef.current?.files?.[0]); + setClientError(problem); + if (problem) e.preventDefault(); + }} + className="space-y-2" + > +
+ setClientError(e.target.files?.[0] ? validate(e.target.files[0]) : null)} + className="text-sm text-slate-600 file:mr-3 file:px-3 file:py-1.5 file:rounded-lg file:border file:border-slate-300 file:bg-white file:text-slate-700 hover:file:bg-slate-50" + /> + +
+

+ Somente PDF, até {PDF_TEMPLATE_MAX_MB} MB. O modelo precisa ter o link do convite (…/r/…), que será trocado pelo + link de cada família. +

+ {error && ( +

+ {error} +

+ )} +
+ ); +} + +// Seção "PDF do convite" da página do evento: modelo, geração em lote e progresso +export function EventPdfPanel({ + uploadAction, + invitationNames, +}: { + uploadAction: (prev: FormState, formData: FormData) => Promise; + invitationNames: Record; +}) { + const { eventId, state, refresh } = useEventPdf(); + const [batchError, setBatchError] = useState(null); + const [starting, setStarting] = useState(false); + + const { template, job, invitations } = state; + const running = job?.status === "running"; + const count = (status: PdfViewStatus) => invitations.filter((inv) => inv.status === status).length; + const toRegenerate = count("not_generated") + count("outdated") + count("error"); + const failed = invitations.filter((inv) => inv.status === "error"); + + const start = async (mode: "all" | "pending") => { + if (mode === "all" && count("generated") + count("outdated") > 0) { + if (!window.confirm(`Gerar novamente os PDFs de todos os ${invitations.length} convites?`)) return; + } + setStarting(true); + setBatchError(null); + try { + const result = await startPdfBatch(eventId, mode); + if (result.error) setBatchError(result.error); + } catch { + setBatchError("Não foi possível iniciar a geração. Tente novamente."); + } finally { + await refresh(); + setStarting(false); + } + }; + + const percent = job && job.total > 0 ? Math.round((job.processed / job.total) * 100) : 0; + + return ( +
+

+

+ + {template ? ( +
+
Modelo
+
+ {template.name} + + Baixar modelo + +
+
Atualizado em
+
+ {dateFormatter.format(new Date(template.updatedAt))} · versão {template.version} +
+
+ ) : ( +

+ Nenhum PDF modelo configurado. Envie o modelo para poder gerar os PDFs dos convites. +

+ )} + + + + {template && ( +
+

+ {count("generated")} gerados · {count("outdated")} com modelo antigo · {count("error")} com erro ·{" "} + {count("not_generated")} não gerados +

+
+ + {toRegenerate > 0 && toRegenerate < invitations.length && ( + + )} +
+ + {job && running && ( +
+

+ Gerando PDFs... {job.processed} de {job.total} concluídos + {job.failed > 0 && · {job.failed} com erro} +

+
+
+
+

{percent}% · você pode continuar usando a página.

+
+ )} + {job && !running && ( +

+ Última geração em lote: {job.processed - job.failed} de {job.total} concluídos + {job.failed > 0 && · {job.failed} com erro} + {job.status === "interrupted" && · interrompida} +

+ )} + {!running && failed.length > 0 && ( +

+ Com erro: {failed.map((inv) => invitationNames[inv.id] ?? "convite").join(", ")} +

+ )} + {batchError && ( +

+ {batchError} +

+ )} +
+ )} +
+ ); +} + +// Status e ações de PDF de um convite (dentro do card do convite) +export function InvitationPdfActions({ invitationId }: { invitationId: string }) { + const { eventId, state, pendingIds, errors, generate } = useEventPdf(); + const pdf: InvitationPdfState = state.invitations.find((inv) => inv.id === invitationId) ?? { + id: invitationId, + status: "not_generated", + error: null, + generatedAt: null, + hasFile: false, + }; + + const busy = pdf.status === "generating" || pendingIds.has(invitationId); + const status: PdfViewStatus = busy ? "generating" : pdf.status; + const hasTemplate = state.template !== null; + const error = errors[invitationId] ?? (status === "error" ? pdf.error : null); + + return ( +
+
+ PDF: + {statusLabels[status]} + {pdf.generatedAt && status === "generated" && ( + em {dateFormatter.format(new Date(pdf.generatedAt))} + )} + + {pdf.hasFile && !busy && ( + + + )} +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/components/admin/WhatsAppMessageField.tsx b/src/components/admin/WhatsAppMessageField.tsx new file mode 100644 index 0000000..4a34041 --- /dev/null +++ b/src/components/admin/WhatsAppMessageField.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { Fragment, useRef, type ReactNode } from "react"; +import { + DEFAULT_WHATSAPP_MESSAGE, + renderWhatsAppMessage, + WHATSAPP_MESSAGE_MAX, + WHATSAPP_URL_MAX, + whatsappShareUrl, + whatsappVariables, + type WhatsAppMessageData, +} from "@/lib/whatsapp-message"; + +// Mesmas regras do WhatsApp: ```mono``` e `código` não aceitam formatação dentro; *negrito*, _itálico_ +// e ~riscado~ precisam encostar no texto (sem espaço logo após abrir ou antes de fechar) e não podem +// estar colados em letras/números do lado de fora. +// Via RegExp: o tsconfig mira ES2017 e o lookbehind (? { + try { + return new RegExp( + "```([\\s\\S]+?)```|`([^`\\n]+)`|(? 0) nodes.push(rest.slice(0, match.index)); + const key = nodes.length; + const [, block, code, marker, inner] = match; + if (block !== undefined || code !== undefined) { + nodes.push( + + {block ?? code} + + ); + } else if (marker === "*") { + nodes.push({formatWhatsApp(inner)}); + } else if (marker === "_") { + nodes.push({formatWhatsApp(inner)}); + } else { + nodes.push({formatWhatsApp(inner)}); + } + rest = rest.slice(match.index + match[0].length); + } + return nodes; +} + +// Emojis de festa e de informação, mais os ícones dos temas. Só emojis antigos (Emoji 11.0, de 2018, ou anterior) e +// sem sequências ZWJ, para não virarem quadrado em aparelhos desatualizados. O resto vem do +// teclado de emojis do sistema. +const quickEmojis = [ + "🎉", "🎊", "🎈", "🎂", "🍰", "🎁", "🎶", "✨", "🥂", "🍾", "💐", "🌸", + "❤️", "💛", "😊", "😍", "🤩", "🙏", "👏", "👉", "📅", "⏰", "📍", "🏠", + "🚗", "📞", "✅", "🦁", "👑", "🦸", "🐳", "🦖", "🦄", "🚀", "🐄", "⚽", +]; + +const formattingTips = [ + { syntax: "*texto*", result: negrito }, + { syntax: "_texto_", result: itálico }, + { syntax: "~texto~", result: riscado }, + { syntax: "```texto```", result: monoespaçado }, +]; + +export function WhatsAppMessageField({ + value, + onChange, + sample, +}: { + value: string; + onChange: (value: string) => void; + // Dados de exemplo para a prévia (o nome e o link variam por convite) + sample: WhatsAppMessageData; +}) { + const textareaRef = useRef(null); + + // Insere a variável ou o emoji na posição do cursor + const insertText = (token: string) => { + const el = textareaRef.current; + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + onChange(value.slice(0, start) + token + value.slice(end)); + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(start + token.length, start + token.length); + }); + }; + + const preview = renderWhatsAppMessage(value, sample); + const urlLength = whatsappShareUrl(preview).length; + // Só a mensagem personalizada é limitada; o servidor confere de novo com os nomes reais dos convites + const custom = value.trim() !== ""; + const charsTooLong = custom && preview.length > WHATSAPP_MESSAGE_MAX; + const urlTooLong = custom && urlLength > WHATSAPP_URL_MAX; + + return ( +
+