ajuste para bloqueio individual dos convites
This commit is contained in:
@@ -14,7 +14,7 @@ Este manual explica como **criar eventos, montar e enviar convites e acompanhar
|
|||||||
- 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.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.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.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)
|
- 2.12 [Prazo de confirmação](#212-prazo-de-confirmação-como-funciona) · 2.13 [Bloquear um convite](#213-bloquear-um-convite)
|
||||||
3. [Experiência do convidado](#3-experiência-do-convidado)
|
3. [Experiência do convidado](#3-experiência-do-convidado)
|
||||||
4. [Perguntas frequentes](#4-perguntas-frequentes)
|
4. [Perguntas frequentes](#4-perguntas-frequentes)
|
||||||
5. [Quando falar com o administrador](#5-quando-falar-com-o-administrador)
|
5. [Quando falar com o administrador](#5-quando-falar-com-o-administrador)
|
||||||
@@ -91,7 +91,7 @@ Clique em um evento para abrir os detalhes.
|
|||||||
- **Prazo para confirmar** (opcional) — último dia para responder
|
- **Prazo para confirmar** (opcional) — último dia para responder
|
||||||
- **Tema do convite** — a prévia logo abaixo mostra as cores e os ícones escolhidos
|
- **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
|
- **Texto do selo** e **Texto do rodapé** (opcionais) — em branco, usam o texto padrão do tema
|
||||||
- **Imagem de fundo** (opcional) — JPG, PNG ou WebP, até **3 MB**. Aparece atrás do convite, com um véu claro por cima para o texto continuar legível, e também no card de pré-visualização do WhatsApp. Para tirar a imagem, marque **Remover a imagem de fundo atual** ao editar o evento. Prefira fotos leves: no WhatsApp, cards com imagem muito pesada podem aparecer sem foto
|
- **Imagem de fundo** (opcional) — JPG, PNG ou WebP, até **3 MB**. Aparece no topo do convite (no cartão com o nome da família), com um véu claro por cima para o texto continuar legível, e também no card de pré-visualização do WhatsApp. Para tirar a imagem, ao editar o evento, clique em **Remover imagem de fundo** e depois em **Salvar alterações**. Prefira fotos leves: no WhatsApp, cards com imagem muito pesada podem aparecer sem foto
|
||||||
- **Mensagem do WhatsApp** (opcional) — texto enviado pelo botão **WhatsApp** de cada convite. Em branco, usa a mensagem padrão (ver [2.5.1](#251-personalizar-a-mensagem-do-whatsapp))
|
- **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.
|
3. Clique em **Criar evento**. Você será levado à página do evento.
|
||||||
|
|
||||||
@@ -413,6 +413,25 @@ Pontos importantes:
|
|||||||
- **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.
|
- **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.
|
- **Zerar respostas** depois do prazo volta todos para pendente no sistema, mas eles continuam aparecendo como ausentes enquanto o prazo estiver encerrado.
|
||||||
|
|
||||||
|
### 2.13 Bloquear um convite
|
||||||
|
|
||||||
|
Use para encerrar as respostas de **um convite só**, sem mudar o prazo do evento nem os outros convites.
|
||||||
|
|
||||||
|
1. Na página do evento, clique em **Bloquear** no convite.
|
||||||
|
2. Confirme. O convite passa a mostrar 🔒 **"Bloqueado — pendentes contam como ausentes"**.
|
||||||
|
|
||||||
|
Enquanto o convite estiver bloqueado:
|
||||||
|
|
||||||
|
- o convidado vê "🔒 O prazo para confirmar presença terminou. O convite não aceita mais alterações." Os botões ✓/✗ e **Confirmar Presença** somem;
|
||||||
|
- se o prazo do evento também já tiver passado, a mensagem é a do prazo, com a data (2.12);
|
||||||
|
- quem não respondeu aparece como **Ausente**, como acontece depois do prazo: no convite, no painel, na página do evento, na edição do convite e no CSV.
|
||||||
|
|
||||||
|
Pontos importantes:
|
||||||
|
|
||||||
|
- **Nada é apagado.** Clique em **Desbloquear** para reverter: os pendentes voltam a aparecer como pendentes e o convite volta a aceitar respostas, se o prazo do evento permitir.
|
||||||
|
- **Bloqueio com a página aberta:** se o convidado deixar a página aberta e tentar enviar depois do bloqueio, a resposta é recusada e a página passa para o modo encerrado.
|
||||||
|
- **Mensagem do WhatsApp:** o botão continua disponível e o texto não muda.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Experiência do convidado
|
## 3. Experiência do convidado
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ Definido em `src/db/schema.ts`. As migrations ficam em `drizzle/` e são aplicad
|
|||||||
| `token` | text, único | Código do link `/r/<token>`: 6 bytes aleatórios em base64url (8 caracteres) |
|
| `token` | text, único | Código do link `/r/<token>`: 6 bytes aleatórios em base64url (8 caracteres) |
|
||||||
| `display_name` | text | Nome exibido ("Família Silva") |
|
| `display_name` | text | Nome exibido ("Família Silva") |
|
||||||
| `responded_at` | timestamptz | Último envio de resposta pela família |
|
| `responded_at` | timestamptz | Último envio de resposta pela família |
|
||||||
|
| `blocked` | boolean, padrão `false` | Bloqueio individual: encerra as respostas deste convite (ver [Prazo de confirmação](#prazo-de-confirmação)) |
|
||||||
| `pdf_status` | enum | `not_generated`, `generating`, `generated` ou `error` |
|
| `pdf_status` | enum | `not_generated`, `generating`, `generated` ou `error` |
|
||||||
| `pdf_template_version` | integer | Versão do modelo usada na última geração **bem-sucedida** |
|
| `pdf_template_version` | integer | Versão do modelo usada na última geração **bem-sucedida** |
|
||||||
| `pdf_generated_at` | timestamptz | Data da última geração bem-sucedida (não nulo = existe arquivo para baixar) |
|
| `pdf_generated_at` | timestamptz | Data da última geração bem-sucedida (não nulo = existe arquivo para baixar) |
|
||||||
@@ -238,6 +239,8 @@ Para mudar o banco: altere `schema.ts`, rode `npm run db:generate` e confira o S
|
|||||||
|
|
||||||
O banco não é alterado. Todas as telas e o CSV usam `effectiveStatus`, então adiar ou apagar o prazo reverte a exibição.
|
O banco não é alterado. Todas as telas e o CSV usam `effectiveStatus`, então adiar ou apagar o prazo reverte a exibição.
|
||||||
|
|
||||||
|
**Bloqueio individual:** `invitations.blocked` tem o mesmo efeito do prazo vencido, mas só naquele convite (`setInvitationBlocked` em `src/app/admin/actions.ts`). Se só o bloqueio encerrou o convite, a mensagem do convidado sai sem a data; com o prazo também vencido, ela mostra a data.
|
||||||
|
|
||||||
### Temas
|
### Temas
|
||||||
|
|
||||||
`src/lib/themes.ts` define os 15 temas (paleta, selo, ícones). Os componentes do convite usam classes `amber-*` do Tailwind, e `themeStyle()` redefine as variáveis `--color-amber-*` no contêiner. Assim, trocar o tema troca a paleta inteira sem mexer nos componentes. Para criar um tema, acrescente um objeto ao array `themes`.
|
`src/lib/themes.ts` define os 15 temas (paleta, selo, ícones). Os componentes do convite usam classes `amber-*` do Tailwind, e `themeStyle()` redefine as variáveis `--color-amber-*` no contêiner. Assim, trocar o tema troca a paleta inteira sem mexer nos componentes. Para criar um tema, acrescente um objeto ao array `themes`.
|
||||||
|
|||||||
1
drizzle/0008_invitation_blocked.sql
Normal file
1
drizzle/0008_invitation_blocked.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "invitations" ADD COLUMN "blocked" boolean DEFAULT false NOT NULL;
|
||||||
499
drizzle/meta/0008_snapshot.json
Normal file
499
drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
{
|
||||||
|
"id": "ded69e84-e2c4-46b9-93cd-b2a10a1b8656",
|
||||||
|
"prevId": "a154e70d-84be-4724-817c-e8e4dfac48f8",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.events": {
|
||||||
|
"name": "events",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "date",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"name": "time",
|
||||||
|
"type": "time",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"venue": {
|
||||||
|
"name": "venue",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"name": "address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"rsvp_deadline": {
|
||||||
|
"name": "rsvp_deadline",
|
||||||
|
"type": "date",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'safari'"
|
||||||
|
},
|
||||||
|
"badge_text": {
|
||||||
|
"name": "badge_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"footer_text": {
|
||||||
|
"name": "footer_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"whatsapp_message": {
|
||||||
|
"name": "whatsapp_message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"background_version": {
|
||||||
|
"name": "background_version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"background_type": {
|
||||||
|
"name": "background_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"pdf_template_version": {
|
||||||
|
"name": "pdf_template_version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"pdf_template_name": {
|
||||||
|
"name": "pdf_template_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"pdf_template_updated_at": {
|
||||||
|
"name": "pdf_template_updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.guests": {
|
||||||
|
"name": "guests",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"invitation_id": {
|
||||||
|
"name": "invitation_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "guest_type",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "rsvp_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"responded_at": {
|
||||||
|
"name": "responded_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"guests_invitation_id_invitations_id_fk": {
|
||||||
|
"name": "guests_invitation_id_invitations_id_fk",
|
||||||
|
"tableFrom": "guests",
|
||||||
|
"tableTo": "invitations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"invitation_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.invitations": {
|
||||||
|
"name": "invitations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"name": "event_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"responded_at": {
|
||||||
|
"name": "responded_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"blocked": {
|
||||||
|
"name": "blocked",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1792548033540,
|
"when": 1792548033540,
|
||||||
"tag": "0007_event_background",
|
"tag": "0007_event_background",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793414801713,
|
||||||
|
"tag": "0008_invitation_blocked",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { AlarmClock, CalendarDays, Clock, Download, Lock, MapPin, Palette } from
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { deleteInvitation, resetInvitationRsvp } from "@/app/admin/actions";
|
import { deleteInvitation, resetInvitationRsvp, setInvitationBlocked } from "@/app/admin/actions";
|
||||||
import { uploadPdfTemplate } from "@/app/admin/pdf-actions";
|
import { uploadPdfTemplate } from "@/app/admin/pdf-actions";
|
||||||
import {
|
import {
|
||||||
ConfirmActionButton,
|
ConfirmActionButton,
|
||||||
@@ -155,6 +155,12 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
|
|||||||
Link: /r/{inv.token}
|
Link: /r/{inv.token}
|
||||||
{inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}</>}
|
{inv.respondedAt && <> · respondeu em {respondedFormatter.format(inv.respondedAt)}</>}
|
||||||
</p>
|
</p>
|
||||||
|
{inv.blocked && (
|
||||||
|
<p className="text-xs text-rose-700 font-medium mt-1 flex items-center gap-1">
|
||||||
|
<Lock aria-hidden="true" className="w-3.5 h-3.5 shrink-0" /> Bloqueado — pendentes contam como
|
||||||
|
ausentes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ShareInvitation
|
<ShareInvitation
|
||||||
token={inv.token}
|
token={inv.token}
|
||||||
@@ -195,6 +201,16 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
|
|||||||
>
|
>
|
||||||
Abrir convite
|
Abrir convite
|
||||||
</Link>
|
</Link>
|
||||||
|
<ConfirmActionButton
|
||||||
|
action={setInvitationBlocked.bind(null, inv.id, !inv.blocked)}
|
||||||
|
label={inv.blocked ? "Desbloquear" : "Bloquear"}
|
||||||
|
confirmMessage={
|
||||||
|
inv.blocked
|
||||||
|
? `Desbloquear o convite de "${inv.displayName}"? Ele volta a aceitar respostas, se o prazo do evento permitir.`
|
||||||
|
: `Bloquear o convite de "${inv.displayName}"? Ele deixa de aceitar respostas e os pendentes contam como ausentes.`
|
||||||
|
}
|
||||||
|
variant="neutral"
|
||||||
|
/>
|
||||||
{inv.respondedAt && (
|
{inv.respondedAt && (
|
||||||
<ConfirmActionButton
|
<ConfirmActionButton
|
||||||
action={resetInvitationRsvp.bind(null, inv.id)}
|
action={resetInvitationRsvp.bind(null, inv.id)}
|
||||||
|
|||||||
@@ -387,6 +387,24 @@ export async function deleteInvitation(invitationId: string): Promise<void> {
|
|||||||
redirect(`/admin/eventos/${deleted.eventId}`);
|
redirect(`/admin/eventos/${deleted.eventId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bloqueia ou libera as respostas de um convite, sem depender do prazo do evento
|
||||||
|
export async function setInvitationBlocked(invitationId: string, blocked: boolean): Promise<void> {
|
||||||
|
await requireOrganizer();
|
||||||
|
if (!isUuid(invitationId)) return;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(invitations)
|
||||||
|
.set({ blocked })
|
||||||
|
.where(eq(invitations.id, invitationId))
|
||||||
|
.returning({ eventId: invitations.eventId, token: invitations.token });
|
||||||
|
if (!updated) return;
|
||||||
|
|
||||||
|
// Os pendentes do convite mudam entre pendente e ausente, então os totais do painel também
|
||||||
|
revalidatePath("/admin");
|
||||||
|
revalidatePath(`/admin/eventos/${updated.eventId}`);
|
||||||
|
revalidatePath(`/r/${updated.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Volta todos os convidados do convite para "pendente"
|
// Volta todos os convidados do convite para "pendente"
|
||||||
export async function resetInvitationRsvp(invitationId: string): Promise<void> {
|
export async function resetInvitationRsvp(invitationId: string): Promise<void> {
|
||||||
await requireOrganizer();
|
await requireOrganizer();
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ const validStatuses = new Set(["confirmed", "declined"]);
|
|||||||
|
|
||||||
// Chamável por POST direto: o token é a única credencial do convidado,
|
// Chamável por POST direto: o token é a única credencial do convidado,
|
||||||
// então tudo é revalidado aqui no servidor.
|
// então tudo é revalidado aqui no servidor.
|
||||||
export async function submitRsvp(submission: RsvpSubmission): Promise<{ success: boolean; closed?: boolean }> {
|
export async function submitRsvp(
|
||||||
|
submission: RsvpSubmission
|
||||||
|
): Promise<{ success: boolean; closed?: boolean; blocked?: boolean }> {
|
||||||
const { token, responses } = submission;
|
const { token, responses } = submission;
|
||||||
|
|
||||||
if (typeof token !== "string" || !Array.isArray(responses) || responses.length === 0) {
|
if (typeof token !== "string" || !Array.isArray(responses) || responses.length === 0) {
|
||||||
@@ -24,14 +26,16 @@ export async function submitRsvp(submission: RsvpSubmission): Promise<{ success:
|
|||||||
|
|
||||||
const invitation = await db.query.invitations.findFirst({
|
const invitation = await db.query.invitations.findFirst({
|
||||||
where: eq(invitations.token, token),
|
where: eq(invitations.token, token),
|
||||||
columns: { id: true },
|
columns: { id: true, blocked: true },
|
||||||
with: { event: { columns: { rsvpDeadline: true } } },
|
with: { event: { columns: { rsvpDeadline: true } } },
|
||||||
});
|
});
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
if (isRsvpClosed(invitation.event.rsvpDeadline)) {
|
const deadlinePassed = isRsvpClosed(invitation.event.rsvpDeadline);
|
||||||
return { success: false, closed: true };
|
if (deadlinePassed || invitation.blocked) {
|
||||||
|
// blocked: só o bloqueio encerrou, então a mensagem sai sem a data do prazo
|
||||||
|
return { success: false, closed: true, blocked: !deadlinePassed };
|
||||||
}
|
}
|
||||||
|
|
||||||
const guestIds = responses.map((r) => r.guestId);
|
const guestIds = responses.map((r) => r.guestId);
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
|
|||||||
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-100 file:px-3 file:py-2 file:text-sm file:font-medium file:text-slate-700 hover:file:bg-slate-200"
|
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-100 file:px-3 file:py-2 file:text-sm file:font-medium file:text-slate-700 hover:file:bg-slate-200"
|
||||||
/>
|
/>
|
||||||
<span className="block text-xs text-slate-500 mt-1">
|
<span className="block text-xs text-slate-500 mt-1">
|
||||||
JPG, PNG ou WebP, até {BACKGROUND_MAX_MB} MB. Aparece atrás do convite, com um véu claro por cima, e também
|
JPG, PNG ou WebP, até {BACKGROUND_MAX_MB} MB. Aparece no topo do convite, com um véu claro por cima, e também
|
||||||
na prévia do link no WhatsApp.
|
na prévia do link no WhatsApp.
|
||||||
</span>
|
</span>
|
||||||
{backgroundError && (
|
{backgroundError && (
|
||||||
@@ -196,16 +196,30 @@ export function EventForm({ action, submitLabel, defaults }: EventFormProps) {
|
|||||||
className="mt-2 h-32 w-full rounded-xl border border-slate-200 object-cover"
|
className="mt-2 h-32 w-full rounded-xl border border-slate-200 object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{removeBackground && <input type="hidden" name="removeBackground" value="on" />}
|
||||||
{defaults?.backgroundUrl && !newBackground && (
|
{defaults?.backgroundUrl && !newBackground && (
|
||||||
<label className="mt-2 flex items-center gap-2 text-sm text-slate-700">
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
<input
|
{removeBackground ? (
|
||||||
type="checkbox"
|
<>
|
||||||
name="removeBackground"
|
<span className="text-sm text-slate-600">A imagem será removida ao salvar.</span>
|
||||||
checked={removeBackground}
|
<button
|
||||||
onChange={(e) => setRemoveBackground(e.target.checked)}
|
type="button"
|
||||||
/>
|
onClick={() => setRemoveBackground(false)}
|
||||||
Remover a imagem de fundo atual
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
||||||
</label>
|
>
|
||||||
|
Desfazer
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRemoveBackground(true)}
|
||||||
|
className="rounded-lg border border-rose-200 px-3 py-1.5 text-sm font-medium text-rose-700 hover:bg-rose-50"
|
||||||
|
>
|
||||||
|
Remover imagem de fundo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Prévia: as classes amber-* abaixo assumem as cores do tema escolhido */}
|
{/* Prévia: as classes amber-* abaixo assumem as cores do tema escolhido */}
|
||||||
|
|||||||
@@ -4,15 +4,27 @@ export function InvitationHero({
|
|||||||
displayName,
|
displayName,
|
||||||
theme,
|
theme,
|
||||||
badgeText,
|
badgeText,
|
||||||
|
backgroundUrl,
|
||||||
}: {
|
}: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
theme: InvitationTheme;
|
theme: InvitationTheme;
|
||||||
badgeText?: string;
|
badgeText?: string;
|
||||||
|
backgroundUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23f59e0b' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E";
|
const patternSvg = "data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23f59e0b' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-b from-amber-100 via-amber-50 to-white p-8 sm:p-12 text-center">
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-b from-amber-100 via-amber-50 to-white p-8 sm:p-12 text-center">
|
||||||
|
{backgroundUrl && (
|
||||||
|
// Foto cobrindo o card, com um véu claro por cima para o texto continuar legível
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,0.6), rgba(255,255,255,0.6)), url('${backgroundUrl}')`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: `url('${patternSvg}')` }} />
|
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: `url('${patternSvg}')` }} />
|
||||||
<div className="absolute top-4 right-4 w-20 h-20 bg-gradient-to-br from-amber-400 to-amber-600 rounded-full opacity-20 blur-2xl" />
|
<div className="absolute top-4 right-4 w-20 h-20 bg-gradient-to-br from-amber-400 to-amber-600 rounded-full opacity-20 blur-2xl" />
|
||||||
<div className="absolute bottom-4 left-4 w-16 h-16 bg-(--theme-accent) rounded-full opacity-20 blur-2xl" />
|
<div className="absolute bottom-4 left-4 w-16 h-16 bg-(--theme-accent) rounded-full opacity-20 blur-2xl" />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState(false);
|
const [submitError, setSubmitError] = useState(false);
|
||||||
const [closed, setClosed] = useState(invitation.rsvpClosed);
|
const [closed, setClosed] = useState(invitation.rsvpClosed);
|
||||||
|
const [deadline, setDeadline] = useState(invitation.event.rsvpDeadline);
|
||||||
|
|
||||||
const theme = getTheme(invitation.theme);
|
const theme = getTheme(invitation.theme);
|
||||||
const hasChanges = guests.some((g) => g.status !== "pending");
|
const hasChanges = guests.some((g) => g.status !== "pending");
|
||||||
@@ -42,8 +43,9 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowSuccess(true);
|
setShowSuccess(true);
|
||||||
} else if (result.closed) {
|
} else if (result.closed) {
|
||||||
// O prazo acabou com a página aberta: pendentes passam a ausentes
|
// O prazo acabou ou o convite foi bloqueado com a página aberta: pendentes passam a ausentes
|
||||||
setClosed(true);
|
setClosed(true);
|
||||||
|
if (result.blocked) setDeadline(undefined);
|
||||||
setGuests((prev) => prev.map((g) => (g.status === "pending" ? { ...g, status: "declined" } : g)));
|
setGuests((prev) => prev.map((g) => (g.status === "pending" ? { ...g, status: "declined" } : g)));
|
||||||
} else {
|
} else {
|
||||||
setSubmitError(true);
|
setSubmitError(true);
|
||||||
@@ -59,21 +61,15 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={themeStyle(theme)}
|
style={themeStyle(theme)}
|
||||||
className="relative min-h-screen bg-gradient-to-b from-amber-50 via-white to-(--theme-accent-soft) py-8 px-4 sm:px-6 lg:px-8"
|
className="min-h-screen bg-gradient-to-b from-amber-50 via-white to-(--theme-accent-soft) py-8 px-4 sm:px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
{invitation.backgroundUrl && (
|
<main className="max-w-2xl mx-auto space-y-6">
|
||||||
// Camada fixa (e não background-attachment: fixed, que falha no iOS) com um véu claro por cima,
|
<InvitationHero
|
||||||
// para o texto continuar legível com qualquer imagem
|
displayName={invitation.displayName}
|
||||||
<div
|
theme={theme}
|
||||||
aria-hidden="true"
|
badgeText={invitation.badgeText}
|
||||||
className="fixed inset-0 bg-cover bg-center"
|
backgroundUrl={invitation.backgroundUrl}
|
||||||
style={{
|
|
||||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.6), rgba(255,255,255,0.6)), url('${invitation.backgroundUrl}')`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<main className="relative max-w-2xl mx-auto space-y-6">
|
|
||||||
<InvitationHero displayName={invitation.displayName} theme={theme} badgeText={invitation.badgeText} />
|
|
||||||
<EventDetails event={invitation.event} />
|
<EventDetails event={invitation.event} />
|
||||||
<GuestList guests={guests} onGuestToggle={handleGuestToggle} submitted={showSuccess} locked={closed} />
|
<GuestList guests={guests} onGuestToggle={handleGuestToggle} submitted={showSuccess} locked={closed} />
|
||||||
<RsvpSelector
|
<RsvpSelector
|
||||||
@@ -81,7 +77,7 @@ export function InvitationView({ invitation }: { invitation: Invitation }) {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
hasChanges={hasChanges}
|
hasChanges={hasChanges}
|
||||||
deadline={invitation.event.rsvpDeadline}
|
deadline={deadline}
|
||||||
closed={closed}
|
closed={closed}
|
||||||
/>
|
/>
|
||||||
{submitError && (
|
{submitError && (
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { date, integer, jsonb, pgEnum, pgTable, text, time, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
import {
|
||||||
|
boolean,
|
||||||
|
date,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgEnum,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
time,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const guestTypeEnum = pgEnum("guest_type", ["adult", "teen", "child"]);
|
export const guestTypeEnum = pgEnum("guest_type", ["adult", "teen", "child"]);
|
||||||
export const rsvpStatusEnum = pgEnum("rsvp_status", ["pending", "confirmed", "declined"]);
|
export const rsvpStatusEnum = pgEnum("rsvp_status", ["pending", "confirmed", "declined"]);
|
||||||
@@ -42,6 +54,8 @@ export const invitations = pgTable("invitations", {
|
|||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
displayName: text("display_name").notNull(),
|
displayName: text("display_name").notNull(),
|
||||||
respondedAt: timestamp("responded_at", { withTimezone: true }),
|
respondedAt: timestamp("responded_at", { withTimezone: true }),
|
||||||
|
// Bloqueio individual: encerra as respostas deste convite, como o prazo faz com o evento inteiro
|
||||||
|
blocked: boolean("blocked").notNull().default(false),
|
||||||
// PDF personalizado: versão do modelo usada na última geração bem-sucedida
|
// PDF personalizado: versão do modelo usada na última geração bem-sucedida
|
||||||
pdfStatus: pdfStatusEnum("pdf_status").notNull().default("not_generated"),
|
pdfStatus: pdfStatusEnum("pdf_status").notNull().default("not_generated"),
|
||||||
pdfTemplateVersion: integer("pdf_template_version"),
|
pdfTemplateVersion: integer("pdf_template_version"),
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ function findInvitationRow(token: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toInvitation(row: InvitationRow): Invitation {
|
function toInvitation(row: InvitationRow): Invitation {
|
||||||
const rsvpClosed = isRsvpClosed(row.event.rsvpDeadline);
|
const deadlinePassed = isRsvpClosed(row.event.rsvpDeadline);
|
||||||
|
const rsvpClosed = deadlinePassed || row.blocked;
|
||||||
|
// Bloqueado antes de o prazo vencer: a data não explica o encerramento e fica fora da mensagem
|
||||||
|
const rsvpDeadline = deadlinePassed || !row.blocked ? row.event.rsvpDeadline : null;
|
||||||
return {
|
return {
|
||||||
token: row.token,
|
token: row.token,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
@@ -48,7 +51,7 @@ function toInvitation(row: InvitationRow): Invitation {
|
|||||||
time: formatEventTime(row.event.time),
|
time: formatEventTime(row.event.time),
|
||||||
venue: row.event.venue,
|
venue: row.event.venue,
|
||||||
address: row.event.address,
|
address: row.event.address,
|
||||||
rsvpDeadline: row.event.rsvpDeadline ? formatEventDate(row.event.rsvpDeadline) : undefined,
|
rsvpDeadline: rsvpDeadline ? formatEventDate(rsvpDeadline) : undefined,
|
||||||
},
|
},
|
||||||
rsvpClosed,
|
rsvpClosed,
|
||||||
theme: row.event.theme,
|
theme: row.event.theme,
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export async function listEventsWithStats() {
|
|||||||
const rows = await db.query.events.findMany({
|
const rows = await db.query.events.findMany({
|
||||||
orderBy: [desc(events.date)],
|
orderBy: [desc(events.date)],
|
||||||
with: {
|
with: {
|
||||||
invitations: { columns: { id: true }, with: { guests: { columns: { type: true, status: true } } } },
|
invitations: {
|
||||||
|
columns: { id: true, blocked: true },
|
||||||
|
with: { guests: { columns: { type: true, status: true } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ export async function listEventsWithStats() {
|
|||||||
invitationCount: event.invitations.length,
|
invitationCount: event.invitations.length,
|
||||||
stats: computeStats(
|
stats: computeStats(
|
||||||
event.invitations.flatMap((inv) =>
|
event.invitations.flatMap((inv) =>
|
||||||
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }))
|
inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed || inv.blocked) }))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -81,7 +84,7 @@ export async function getEventWithInvitations(eventId: string) {
|
|||||||
|
|
||||||
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
|
const rsvpClosed = isRsvpClosed(event.rsvpDeadline);
|
||||||
const invitationList = event.invitations.map((inv) => {
|
const invitationList = event.invitations.map((inv) => {
|
||||||
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) }));
|
const guestList = inv.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed || inv.blocked) }));
|
||||||
return { ...inv, guests: guestList, stats: computeStats(guestList) };
|
return { ...inv, guests: guestList, stats: computeStats(guestList) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ export async function getInvitationForEdit(invitationId: string) {
|
|||||||
});
|
});
|
||||||
if (!invitation) return undefined;
|
if (!invitation) return undefined;
|
||||||
|
|
||||||
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline);
|
const rsvpClosed = isRsvpClosed(invitation.event.rsvpDeadline) || invitation.blocked;
|
||||||
return {
|
return {
|
||||||
...invitation,
|
...invitation,
|
||||||
guests: invitation.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) })),
|
guests: invitation.guests.map((g) => ({ ...g, status: effectiveStatus(g.status, rsvpClosed) })),
|
||||||
|
|||||||
Reference in New Issue
Block a user