# Backoffice API Documentation

## Overview

A API de Backoffice permite integração externa para sincronizar dados de eventos, convidados, vouchers, agenda, acompanhantes e notificações do **Belvitur Eventos** — aplicativo completo para gestão de eventos corporativos.

### Funcionalidades do App

O aplicativo oferece uma experiência completa para participantes e organizadores:

- **Credenciamento Digital** — Check-in via QR Code ou NFC, com passes no Apple/Google Wallet e funcionamento offline
- **Agenda Personalizada** — Programação individual com favoritos, lembretes, confirmação de presença e Q&A
- **Vouchers Interativos** — Pré-seleção e resgate de hospedagem, transporte, alimentação, seguro viagem e lazer
- **Chat do Evento** — Comunicação em tempo real com fotos, reações, respostas diretas e moderação
- **Networking** — Troca de contatos via QR Code com anotações e exportação vCard
- **Acompanhantes** — Gestão unificada de credenciais, vouchers e documentos do grupo familiar
- **Gamificação** — Pontos, badges e leaderboard por participação ativa
- **Pesquisas e Enquetes** — Feedback interativo com resultados em tempo real
- **Notificações Push** — Multi-canal (push, email, WhatsApp) via OneSignal com preferências configuráveis
- **Galeria de Fotos** — Upload e visualização de fotos do evento
- **Multilíngue** — Interface em PT/EN/ES com tradução automática de conteúdo por IA
- **Acesso Offline** — Cache local com sincronização automática
- **Landing Page Pública** — Página por evento com countdown, agenda e personalização de marca (WCAG)

**Base URL:**
```
https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api
```

 **Versão:** 5.13.0

---

## Changelog

### v5.13.0 — Fuso horário do evento (`timezone`)

`POST /events` e `PUT /events/{id}` aceitam o campo opcional **`timezone`** (identificador IANA, ex.: `America/Sao_Paulo`, `America/New_York`, `Europe/Lisbon`, `Asia/Tokyo`).

- **Default:** `America/Sao_Paulo` quando o campo não é enviado.
- **Validação:** identificador IANA válido; valores desconhecidos retornam `400 Validation failed: timezone must be a valid IANA identifier (...)`.
- **Para que serve:** ancora os cálculos derivados de datas/horas no fuso do local do evento — jornada do convidado (notificações empáticas), alertas Cirium de voos, clima do aeroporto e `quiet_hours` quando não houver `quiet_hours_timezone` próprio definido.
- **Não altera** a wall-clock literal exibida em `start_date`/`end_date`, vouchers e agenda. A regra v5.11.0 continua válida: a hora cadastrada é a hora exibida, sem conversão.

Exemplo:
```json
PUT /events/{id}
{ "timezone": "Europe/Lisbon" }
```

### v5.12.0 — Cancelamento de evento exclusivo via API

O status `cancelled` em eventos passa a ser controlado **exclusivamente pela API** — o painel do organizador não expõe mais o botão "Cancelar evento". Use `PUT /events/{id}` com `"status": "cancelled"` para cancelar e `"status": "upcoming"` (ou outro válido) para reativar.

**Regras de visibilidade no app quando `status = cancelled`:**

- O evento aparece com badge **"Cancelado"** apenas para convidados que já confirmaram presença (`confirmed` ou `checked_in`).
- Convidados ainda em `pending` deixam de ver o evento na listagem.
- O evento permanece visível como cancelado até a `end_date` (ou `start_date` quando não houver `end_date`). Depois disso é ocultado.
- O cron horário de atualização de status (`update-event-status`) **ignora eventos cancelados** — não transiciona automaticamente para `upcoming`/`ongoing`/`past`. A reativação só acontece via API.

Nenhum outro endpoint foi alterado.

### v5.11.0 — Datas e horas preservadas literalmente (sem conversão de fuso)


Os campos de data/hora dos endpoints de **eventos**, **vouchers** e **agenda** agora são armazenados **exatamente como foram enviados**, ignorando qualquer conversão de fuso horário. O app exibe esses valores literalmente — a wall-clock cadastrada via API é a wall-clock que o convidado vê.

**Campos afetados:**

| Recurso | Campos |
|---------|--------|
| `events` | `start_date`, `end_date` |
| `vouchers` | `valid_from`, `valid_to` |
| `agenda_items` | `start_time`, `end_time` |

**Como funciona:**

A API extrai os componentes `YYYY-MM-DD HH:MM:SS` da string recebida e os armazena ancorados em UTC, sem deslocamento. Qualquer sufixo de fuso (`Z`, `+HH:MM`, `-HH:MM`) é **descartado** — apenas a wall-clock importa.

Exemplos (todos produzem o mesmo armazenamento e o mesmo valor exibido no app):

```
"2026-06-15T09:00:00"          → guardado/exibido como 15/06/2026 09:00
"2026-06-15T09:00:00Z"         → guardado/exibido como 15/06/2026 09:00
"2026-06-15T09:00:00-03:00"    → guardado/exibido como 15/06/2026 09:00
"2026-06-15T09:00:00+02:00"    → guardado/exibido como 15/06/2026 09:00
"2026-06-15"                   → guardado/exibido como 15/06/2026 00:00
```

**Recomendação para integrações:** envie a data/hora local do evento, sem offset (ex.: `"2026-06-15T09:00:00"`). Se o sistema de origem precisar incluir offset, fique tranquilo — ele é ignorado e a wall-clock prevalece.

**Endpoints afetados:** `POST /events`, `PUT /events/{id}`, `POST /vouchers`, `POST /vouchers/bulk`, `PUT /vouchers/{id}`, `POST /agenda`, `POST /agenda/bulk`, `PUT /agenda/{id}`.



### v5.10.0 — Vínculo de convite com verificação de contato + re-vínculo automático em PUT

Para evitar que um convite seja vinculado a uma conta sem comprovar a posse do canal de contato (LGPD / segurança), o auto-link de `pending_guests` agora exige que o canal de match esteja **verificado** no perfil do usuário.

**Regra de vínculo:**

| Canal usado no match | Exige verificação? |
|----------------------|--------------------|
| `email` | Sim — `email_verified_at` no perfil |
| `phone` / `phone_e164` (WhatsApp) | Sim — `phone_verified_at` no perfil |
| `cpf` | Não (documento equivale a prova) |
| `passport_number` | Não (documento equivale a prova) |

Se o convite bater apenas por um canal **ainda não verificado**, o sistema:
1. **Não** vincula o convite imediatamente.
2. Cria um registro interno de "hold" (`pending_guest_verification_holds`).
3. Envia uma notificação in-app do tipo `warning` ("Convite aguardando verificação") para o usuário.
4. O convite só aparece para aceite no app depois que o usuário verificar o canal correspondente (clicar no magic link de email ou validar o OTP do WhatsApp). A resolução do hold é automática via trigger nos campos `email_verified_at` / `phone_verified_at` do perfil.

**Re-vínculo em PUT (novidade):**

Sempre que o organizador atualizar um convite via API e alterar/adicionar `email`, `phone`, `phone_e164`, `cpf` ou `passport_number`, o sistema **reexecuta o matching** contra os perfis cadastrados. Antes desta versão, a busca por usuário existente acontecia apenas na criação (`POST /guests`). Agora também acontece em `PUT /guests/{id}` quando o convidado ainda está pendente:

- Edição introduz um canal **verificado** que casa com um perfil → vincula automaticamente e dissolve o hold (se houver).
- Edição introduz apenas canal **não verificado** → cria/mantém o hold com a notificação.
- Nenhum identificador mudou ou nenhum perfil bate → comportamento atual (segue como pending).

**Endpoints afetados:** `POST /guests`, `PUT /guests/{id}` (quando atualiza identificadores de um convidado ainda pendente).

**Impacto para integrações:** nenhuma mudança de payload nem de schema de resposta. Comportamento totalmente server-side e idempotente — pode-se reenviar o mesmo PUT sem efeitos colaterais.

### v5.9.0 — RG opcional por default + Introspecção, broadcasts agendados, rate-limit DB-backed

- **RG opcional por default**: o campo `rg` deixa de ser obrigatório no cadastro padrão de convidados (mesmo brasileiros). Para exigi-lo em um evento específico, inclua `"rg"` no array `required_guest_fields` ao criar/editar o evento — mesma mecânica usada para `passport`, `nationality`, `yellow_fever_vaccine` e `visa`. Quando exigido, o convidado precisará preenchê-lo no app antes de acessar o evento.
- **Novo endpoint** `GET /schemas/vouchers` — descobre dinamicamente `top_level_required`, `guests_contract`, `date_fields` (com aliases `valid_until`) e schema por `type` de voucher.
- **Novo recurso** `/notification-broadcasts` (`POST`/`GET`/`DELETE`) para agendar notificações futuras com a mesma engine de cascata multi-canal.
- **Paginação** em `GET /vouchers` via `?limit=` (max 500) e `?offset=`; resposta inclui `pagination.{total,limit,offset,has_more}` e header `X-Total-Count`.
- **Rate-limit DB-backed** (sliding window 60s) com fast-path in-memory; respostas `429` carregam `retry_after_seconds` e header `Retry-After`. Limites publicados em `Rate Limits`.
- **Alias `body`** aceito em `POST /notifications` como sinônimo de `message` (compat com payloads OneSignal/FCM).
- **Hardening:** `POST /events` rejeita explicitamente `name`/`location` > 200 chars (antes truncava silenciosamente). `POST /pending-guests` retorna `405` com instrução acionável.


### v5.8.1 — LGPD: redação de dados de perfil para convidados que não aceitaram o convite


Para cumprir a LGPD, os endpoints `GET /guests` e `GET /guests/{id}` agora retornam apenas dados básicos de contato (`id`, `full_name`, `email`, `phone`) no objeto `profiles` enquanto o convidado estiver com status `pending` ou `declined`. Demais campos do perfil (CPF, passaporte, endereço, datas, documentos, vistos, restrições, etc.) só são expostos após o consentimento — isto é, quando o convidado aceita o convite (`status = confirmed` ou `checked_in`).

Convidados em `pending_guests` (que ainda não criaram conta) também tiveram `cpf` e `passport_number` removidos da resposta — apenas `full_name`, `email` e `phone` são retornados.

Nenhuma alteração em payloads de envio (POST/PUT) — a coleta de dados continua igual; apenas a leitura passa a respeitar o consentimento.

### v5.8.0 — Datas próprias por quarto em reservas de hotel

Cada item em `rooms[]` (POST e PUT de `/hotel-reservations`) agora aceita `check_in` e `check_out` opcionais (ISO 8601 com horário). Quando informados, prevalecem sobre as datas do hotel e são gravados em `details.custom_check_in` / `details.custom_check_out` do voucher.

**Validações:**
- `check_in` e `check_out` devem ser enviados juntos (ou ambos, ou nenhum).
- `check_out` deve ser posterior a `check_in`.
- Quartos com datas próprias **exigem `guests[]` preenchido** na mesma chamada — não participam da sugestão automática de rooming. Falha retorna `400` no campo do quarto correspondente.

**Impacto no Rooming List:**
- `suggest-rooming` ignora quartos com `details.custom_check_in` ou `details.custom_check_out` preenchidos; eles continuam visíveis no relatório com seus hóspedes já alocados.
- O `summary` da resposta inclui `rooms_with_custom_dates`.
- O export XLSX da Rooming List traz colunas `Check-in Hotel`, `Check-out Hotel`, `Check-in Efetivo`, `Check-out Efetivo` e `Datas Customizadas` (Sim/Não).

**Compatibilidade:** quartos sem `check_in`/`check_out` continuam herdando as datas do hotel — comportamento anterior preservado.

### v5.7.0 — Recusa permanente de voucher

O convidado agora pode marcar um voucher como **definitivamente não utilizado** (irreversível), por exemplo quando a organização vai cancelar a reserva associada.

**Novos campos em `voucher_guests`:**
- `permanently_declined_at` *(timestamptz)* — Data/hora da recusa permanente.
- `permanent_decline_reason` *(string, opcional)* — Motivo informado pelo convidado.

**Comportamento:**
- A ação **não pode ser desfeita** pelo convidado nem pela API.
- O voucher **desaparece** da listagem do convidado no app.
- Continua acessível pela API para que a organização possa cancelar a reserva correspondente.

**Novo filtro em `GET /vouchers`:**
- `permanently_declined=true` — lista apenas vouchers com recusa permanente.

**Novo evento de webhook:** `voucher.permanently_declined` (ver seção *Webhooks*).

**Auditoria (`GET /vouchers/changes`):** novos `change_type` agora também são emitidos para mudanças em `voucher_guests` — `declined`, `undeclined` e `permanently_declined`.

### v5.6.1 — Toggles de Networking Meetings e Facematching

`POST /events` e `PUT /events/{id}` agora aceitam dois novos toggles booleanos:

- `networking_meetings_enabled` *(boolean, opcional, default `false`)* — habilita o módulo de **reuniões 1:1** dentro do app do evento (slots de 30 min, 8h–20h, agenda automática ao aceitar).
- `facematching_enabled` *(boolean, opcional, default `false`)* — habilita o **face matching** (reconhecimento facial) para identificar convidados em fotos da galeria do evento.

Ambos seguem o mesmo padrão dos toggles existentes (`gamification_enabled`, `photo_gallery_enabled`, `event_chat_enabled`):

- Se omitidos no `POST`, assumem `false`.
- No `PUT`, se ausentes, mantêm o valor atual; se enviados, substituem.
- São retornados no `GET /events` e `GET /events/{id}`.

### v5.6.0 — Vincular vouchers por `external_id` + ajustes em eventos

**Vouchers — novos identificadores em `guests[]`:**
Cada item do array `guests` agora aceita **qualquer** dos seguintes identificadores:

- `user_id` — UUID do usuário registrado
- `guest_id` — UUID do registro em `event_guests`
- `pending_guest_id` — UUID do registro em `pending_guests`
- `external_id` — identificador externo do convidado (string, ex.: ID do CRM/ERP)
- `email` — e-mail do convidado (registrado ou pendente)

**Prioridade quando mais de um identificador é enviado no mesmo item:**
`user_id > guest_id > pending_guest_id > external_id > email`

**Resolução de `external_id`** (escopo: o `event_id` informado no voucher):
1. Busca em `event_guests` pelo `external_id` no evento.
2. Se não achar, consulta a tabela de aliases (preserva o vínculo após merges de conta / reconvites).
3. Se ainda não achar, cai em `pending_guests` pelo mesmo `external_id`.

Se nenhuma das fontes resolver o identificador, o voucher é criado e o item retorna em `errors[]` com motivo. Os demais convidados resolvidos são vinculados normalmente.

**Endpoints afetados:**
- `POST /vouchers`
- `POST /vouchers/bulk`
- `POST /vouchers/{id}/guests`

**Eventos — ajustes recentes:**
- `POST /events` e `PUT /events/{id}` rejeitam status `upcoming`/`ongoing` quando `end_date` está no passado (use `completed`/`past` para eventos finalizados).
- `PUT /events/{id}` cria automaticamente uma cópia interna de `cover_image` / `buyer_logo` quando recebe URLs externas (mesmo comportamento do POST), retornando `image_storage` / `logo_storage` no response.
- `PUT /events/{id}` valida `required_guest_document_uploads` contra os `required_guest_fields` já existentes, mesmo quando este último não é enviado no PUT.
- Atualizar `destinations` no PUT dispara regeneração assíncrona do guia de destino (PT/EN/ES).
- Eventos criados via OAuth ficam isolados por `created_by_oauth_client_id` — o cliente só lê/edita/deleta o que ele criou.

### v5.5.3 — Correção de PUT em vouchers com webhooks

Corrigido o erro em `PUT /vouchers/{id}` causado por um webhook interno que tentava ler `external_id` diretamente da tabela de vouchers. Esse campo pertence aos convidados, não ao voucher, e por isso qualquer atualização de voucher podia falhar com erro de banco.

O comportamento de atualização permanece o mesmo:
- `PUT /vouchers/{id}` aceita o mesmo payload do `POST` para os campos editáveis.
- `event_id`, `guests`, `user_id` e `email` continuam sendo ignorados silenciosamente no PUT.
- Strings vazias (`""`) continuam sendo ignoradas.
- Apenas `null` explícito limpa campos nullable.
- As validações de tipo, datas, status, IATA e `reservation_status` continuam preservadas.

### v5.5.1 — Comportamento padronizado de campos vazios e `null` em PUT

Padronização do tratamento de campos em todos os endpoints `PUT` para permitir reuso direto do payload de `POST` sem efeitos colaterais:

- **Campo ausente**: mantém valor atual (sem alteração).
- **Campo com string vazia (`""`)**: ignorado — tratado como "não enviado". Útil para integrações que sempre enviam o payload completo, mesmo com campos em branco.
- **Campo com `null` explícito**: limpa o valor no banco (apenas em campos nullable). Se o campo não for nullable (ex: `title`, `code`, `valid_from`, `start_time`), o `null` é silenciosamente ignorado.
- **Campos identificadores (`event_id`, `user_id`, `email`, `guests`)**: silenciosamente ignorados em PUT — permite reusar o body exato do POST.

Validadores corrigidos para não escreverem `undefined` em campos do banco. Booleanos (`requires_guest_input`, `requires_usage_control`, `requires_confirmation`, `allow_qa`) agora só são alterados quando enviados como booleano de verdade — antes, qualquer valor não-`true` virava `false`, sobrescrevendo o estado existente.

Correção adicional em vouchers: `PUT /vouchers/{id}` aceita `reservation_status` (`pending_reservation`, `confirmed`, `cancelled`) e o status `cancelled` foi alinhado no banco, evitando falhas ao reutilizar payloads completos da criação/edição.

Endpoints afetados: `PUT /events/{id}`, `PUT /guests/{id}`, `PUT /vouchers/{id}`, `PUT /agenda/{id}`, `PUT /hotel-reservations/{id}`.

### v5.5.0 — PUT aceita o mesmo payload do POST

Os endpoints `PUT` agora aceitam **todos os campos** suportados pelo respectivo `POST`, mantendo todas as validações existentes. Comportamento de merge: campos não enviados permanecem inalterados; campos obrigatórios só são exigidos quando explicitamente enviados.

Endpoints atualizados:
- `PUT /events/{id}` — já aceitava todos os campos editáveis do POST.
- `PUT /guests/{id}` — aceita os mesmos campos de perfil/identidade do POST (`full_name`, `phone`, `email`, `cpf`, `passport_number`, `role`, `status`, `nfc_uid`, `external_id` e todos os campos de profile estendido).
- `PUT /vouchers/{id}` — agora aceita também `requires_guest_input` e `hotel_reservation_id`. Quando `type` + `details` são enviados juntos, aplica as mesmas validações por tipo do POST (datas/horários obrigatórios por tipo de voucher) e validações de IATA para `flight`/`pre_flight`.
- `PUT /agenda/{id}` — já aceitava todos os campos editáveis do POST.
- `PUT /hotel-reservations/{id}` — agora aceita também `rooms[]` (mesma estrutura do POST). Quartos enviados são **adicionados** à reserva existente (comportamento aditivo) com seus respectivos `voucher_guests`.

### v5.4.0 — Deletar Vínculo de Acompanhante por Pending Guest ID

Novo método alternativo para deletar vínculos de acompanhantes usando `companion_pending_guest_id` como identificador, além do ID direto do link.

#### Novo Endpoint

```http
DELETE /companions?companion_pending_guest_id={uuid}&event_id={uuid}
```

| Parâmetro | Tipo | Obrigatório | Descrição |
|-----------|------|-------------|-----------|
| `companion_pending_guest_id` | UUID | Sim | ID do convidado pendente vinculado como acompanhante |
| `event_id` | UUID | Sim | ID do evento |

**Resposta:** `{ "success": true }`

### v5.2.0 — Tipos de Quarto com Capacidades Diferentes

Suporte a capacidade individual por quarto e categorização de quartos via `max_occupancy` e `room_type` no array `rooms[]`.

#### Novos Campos em `rooms[]`

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `max_occupancy` | integer | Capacidade máxima do quarto (fallback: `rooming_config.max_guests_per_room`) |
| `room_type` | string | Categoria do quarto (ex: "Standard", "Suíte Deluxe", "Triplo") |

- Valores são armazenados automaticamente no `details` JSONB do voucher
- O algoritmo de rooming respeita `max_occupancy` individual de cada quarto
- Retrocompatível — quartos sem `max_occupancy` usam o valor global

### v5.1.0 — Reservas de Hotel com Quartos Pré-Alocados + Capacidade

Melhorias no endpoint de Hotel Reservations para suportar criação de quartos com convidados pré-alocados em uma única chamada e controle de capacidade total.

#### Novos Campos

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `total_rooms` | integer | Número total de quartos disponíveis na reserva (inclui quartos ainda não criados no sistema) |
| `rooms` | array | Quartos a criar automaticamente com convidados pré-alocados (opcional, apenas no POST) |

#### `POST /hotel-reservations` — Novo campo `rooms[]`

Permite criar a reserva e seus quartos (vouchers hotel) com convidados alocados em uma única chamada:

```json
{
  "event_id": "uuid-do-evento",
  "title": "Reserva Copacabana Palace",
  "hotel_name": "Copacabana Palace",
  "code": "CP-2026-001",
  "check_in": "2026-06-15T15:00:00Z",
  "check_out": "2026-06-18T12:00:00Z",
  "total_rooms": 15,
  "rooms": [
    {
      "title": "Quarto 301 - Suíte Deluxe",
      "code": "CP-301",
      "details": { "room_type": "Suíte Deluxe", "floor": "3º andar" },
      "guests": [
        { "email": "joao@email.com" },
        { "user_id": "uuid-maria" }
      ]
    },
    {
      "title": "Quarto 302 - Standard",
      "code": "CP-302",
      "details": { "room_type": "Standard" },
      "guests": []
    }
  ]
}
```

- Quartos com `guests[]` preenchido recebem `allocated_by: "api"` no `voucher_guests`
- Quartos com `guests: []` ficam vazios para alocação dinâmica via rooming
- `rooms` é opcional — se omitido, comportamento atual é preservado
- `valid_from`/`valid_to` dos vouchers são herdados de `check_in`/`check_out`

#### `total_rooms` no summary do Rooming List

O campo `total_rooms` agora aparece no summary dos endpoints de rooming-list, diferenciando quartos disponíveis na reserva (`total_rooms`) dos quartos já criados no sistema (`created_rooms`).

### v5.0.0 — Vouchers Multi-Guest + Reservas de Hotel Agrupadas

Reestruturação completa do sistema de vouchers para suportar **múltiplos convidados por voucher** e **agrupamento de quartos em reservas de hotel**.

#### Nova Arquitetura

```
vouchers (1 registro = 1 serviço/quarto)
  ├── voucher_guests (N registros = convidados vinculados)
  │     user_id, pending_guest_email, status individual
  └── hotel_reservation_id (agrupa quartos de um mesmo hotel)

hotel_reservations (1 registro = 1 reserva de hotel)
  ├── event_id, title, code, hotel_name, check_in, check_out, total_rooms
  └── vouchers[] (quartos vinculados)
```

#### Tabela `voucher_guests`

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `id` | UUID | ID do registro |
| `voucher_id` | UUID | ID do voucher |
| `user_id` | UUID | ID do usuário (null se pendente) |
| `pending_guest_email` | string | Email do convidado pendente |
| `status` | enum | Status individual: `active`, `used`, `expired`, `cancelled` |
| `allocated_by` | string | Origem da alocação: `api`, `rooming_auto`, `manual` |
| `declined_at` | timestamp | Data/hora da recusa (reversível) |
| `decline_reason` | string | Motivo da recusa reversível |
| `permanently_declined_at` | timestamp | Data/hora da recusa **permanente** (irreversível — voucher some do app) |
| `permanent_decline_reason` | string | Motivo da recusa permanente |
| `guest_input_completed_at` | timestamp | Data/hora do preenchimento |

---

## Autenticação OAuth 2.0

A API utiliza OAuth 2.0 com fluxo Client Credentials para autenticação segura.

### Obtendo um Access Token

**Endpoint de Token:**
```
POST https://api-eventosapp.beflytech.com.br/api/v1/oauth-token
```

**Requisição:**
```http
POST /oauth-token
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "SEU_CLIENT_ID",
  "client_secret": "SEU_CLIENT_SECRET"
}
```

**Resposta de Sucesso (200):**
```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read write"
}
```

### Usando o Access Token

```bash
curl -H "Authorization: Bearer SEU_ACCESS_TOKEN" \
     https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/events
```

### Revogando um Token

```http
POST /oauth-token/revoke
Content-Type: application/x-www-form-urlencoded

token=SEU_ACCESS_TOKEN
```

### Escopos

| Escopo | Descrição | Operações |
|--------|-----------|-----------|
| `read` | Leitura de dados | GET |
| `write` | Escrita de dados | POST, PUT, DELETE |
| `admin` | Administração | Gestão de clientes OAuth |

Escopos granulares por recurso também são suportados (ex: `events:write`, `guests:read`).

### Migração da API Key (Deprecated)

A autenticação via header `x-api-key` ainda funciona para compatibilidade, mas está **deprecated** e será removida em versões futuras. Migre para OAuth 2.0.

---

## Validação de Entrada

A API implementa validação rigorosa em todas as entradas para garantir segurança e integridade dos dados.

### Limites de Caracteres

| Campo | Limite Máximo |
|-------|---------------|
| Nomes/Títulos | 200 caracteres |
| Descrições/Mensagens | 1.000 caracteres |
| URLs (imagens) | 2.000 caracteres |
| Códigos de voucher | 50 caracteres |
| Telefone | 20 caracteres |
| WhatsApp | 50 caracteres |
| Email | 255 caracteres |

### Formatos Obrigatórios

| Tipo | Formato | Exemplo |
|------|---------|---------|
| UUID | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | `550e8400-e29b-41d4-a716-446655440000` |
| Data/Hora | ISO 8601 | `2025-01-15T09:00:00Z` ou `2025-01-15` |
| Email | Formato padrão | `usuario@email.com` |
| Telefone | 6-20 caracteres (dígitos, espaços, +, -, parênteses) | `+5511999999999` |
| Cor Hex | `#XXXXXX` | `#1e40af` |

### Operações em Lote

- Máximo de **100 itens** por operação bulk
- Cada item é validado individualmente
- Se qualquer item falhar na validação, toda a operação é rejeitada

---

## Endpoints

### Events (Eventos)

#### Listar Eventos
```http
GET /events
```

**Response:**
```json
{
  "events": [
    {
      "id": "uuid",
      "name": "Nome do Evento",
      "description": "Descrição",
      "location": "Local",
      "start_date": "2025-01-15T09:00:00Z",
      "end_date": "2025-01-15T18:00:00Z",
      "status": "upcoming",
      "cover_image": "https://...",
      "brand_color_primary": "#1e40af",
      "brand_color_secondary": "#60a5fa",
      "background_color": "#f8fafc",
      "organizer_name": "Organizador",
      "organizer_email": "contato@organizador.com",
      "buyer_name": "Empresa Contratante",
      "buyer_logo": "https://storage.example.com/logo.png",
      "whatsapp_support": "+5511999999999",
      "destinations": [
        { "city": "Cancún", "country": "México", "arrival_date": "2025-01-14", "departure_date": "2025-01-16" }
      ]
    }
  ]
}
```

#### Obter Evento
```http
GET /events/{id}
```

#### Criar Evento
```http
POST /events
Content-Type: application/json

{
  "name": "Conferência Tech 2025",
  "description": "A maior conferência de tecnologia",
  "location": "São Paulo, SP",
  "latitude": -23.5505,
  "longitude": -46.6333,
  "start_date": "2025-06-15T09:00:00Z",
  "end_date": "2025-06-15T18:00:00Z",
  "timezone": "America/Sao_Paulo",
  "status": "upcoming",
  "cover_image": "https://example.com/event-cover.jpg",
  "brand_color_primary": "#1e40af",
  "brand_color_secondary": "#60a5fa",
  "background_color": "#f8fafc",
  "organizer_name": "Tech Events",
  "organizer_email": "contato@techevents.com.br",
  "buyer_name": "Empresa ABC",
  "buyer_logo": "https://example.com/logo-abc.png",
  "whatsapp_support": "+5511999999999",
  "gamification_enabled": true,
  "photo_gallery_enabled": true,
  "photo_gallery_public_upload": true,
  "event_chat_enabled": true,
  "networking_meetings_enabled": true,
  "facematching_enabled": true,
  "required_guest_fields": ["passport", "nationality", "yellow_fever_vaccine", "visa", "rg"],
  "required_guest_document_uploads": ["passport", "yellow_fever_vaccine"],

  "destinations": [
    { "city": "Cancún", "country": "México", "arrival_date": "2025-06-14", "departure_date": "2025-06-16" }
  ],
  "custom_guest_fields": [
    { "key": "crm", "label": "CRM", "type": "text", "required": true },
    { "key": "gender", "label": "Sexo", "type": "select", "options": ["Masculino", "Feminino", "Outro"], "required": false }
  ],
  "terms_and_conditions": "Ao confirmar presença, o participante declara estar ciente de que...",
  "invitation_email_text": "Você foi convidado para participar deste evento exclusivo!",
  "invitation_whatsapp_text": "Olá! Você foi convidado para o evento. Baixe o app para confirmar.",
  "invitation_preferred_channel": "email",
  "publish_status": "draft"
}
```


**Resposta de Sucesso (201):**
```json
{
  "event": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Conferência Tech 2025",
    "status": "upcoming",
    "publish_status": "draft",
    "published_at": null,
    "created_at": "2025-01-15T10:00:00Z",
    "..."
  },
  "landing_page_url": "https://belvitureventos.lovable.app/e/550e8400-e29b-41d4-a716-446655440000"
}
```

**Landing Page Pública:**
A `landing_page_url` é uma página web pública (sem necessidade de login) que exibe informações do evento, contador regressivo, programação, e links para download do app.

#### Ciclo de vida e visibilidade no app (`publish_status` + `invited_at`)

Para dar tempo do organizador finalizar a configuração antes dos convidados verem o evento no app, existe um controle em duas camadas — **totalmente administrado via API/backoffice** (não há UI no app para isso):

1. **`events.publish_status`** — `draft` (padrão para eventos novos) ou `published`.
2. **`event_guests.invited_at`** — preenchido automaticamente quando o convite é disparado pelo endpoint de envio de convite (`POST /guests/{id}/invite` ou equivalente). `NULL` significa que o convite ainda não foi enviado.

Regras de visibilidade no app, na view do convidado:

| Papel do convidado (`role`) | Vê evento em `draft`? | Vê evento `published` antes do convite ser disparado? |
| --- | --- | --- |
| `organizer`, `admin`, `buyer` | ✅ sim (imediatamente após ser adicionado) | ✅ sim |
| `guest`, `supplier` | ❌ não | ❌ não — só após `invited_at` ser preenchido |

Quando o evento volta para `draft`, convidados comuns deixam de enxergá-lo até nova publicação.

**Fluxo recomendado:**
1. `POST /events` (cria com `publish_status: "draft"` por padrão).
2. `POST /guests` para cadastrar todos os convidados (incluindo organizadores). Convidados com `role` `organizer`/`admin`/`buyer` já enxergam o evento e podem testar.
3. `PUT /events/{id}` com `{ "publish_status": "published" }` quando estiver pronto. `published_at` é preenchido automaticamente.
4. Disparar os convites (endpoint de envio); cada disparo bem-sucedido preenche `invited_at` e o convidado passa a ver o evento.

> **Eventos existentes** foram migrados como `published` e todos os `event_guests` ganharam `invited_at` retroativo (ninguém perde acesso).


**Campos obrigatórios:** `name`, `start_date`, `end_date`

**Validações:**
- `name`: string, máx. 200 caracteres
- `description`: string, máx. 1.000 caracteres
- `location`: string, máx. 200 caracteres
- `latitude`/`longitude`: coordenadas geográficas válidas
- `start_date`/`end_date`: datas ISO válidas, end_date após start_date. Sufixos de fuso (`Z`, `+HH:MM`) são descartados — o app exibe a wall-clock literalmente (ver Changelog v5.11.0).
- `timezone`: identificador IANA (ex.: `America/Sao_Paulo`, `America/New_York`, `Europe/Lisbon`, `Asia/Tokyo`). **Opcional** — default `America/Sao_Paulo`. Usado para: (1) interpretar a wall-clock cadastrada nos vouchers/agenda na hora correta do local do evento (jornada do convidado, alertas Cirium, clima do aeroporto), (2) ancorar `quiet_hours` quando o evento não definir `quiet_hours_timezone` próprio. **Não altera** os valores literais exibidos em `start_date`/`end_date` — apenas o fuso "âncora" para cálculos derivados.
- `cover_image`: URL da imagem (automaticamente armazenada no storage)
- `buyer_logo`: URL do logotipo da empresa compradora (automaticamente armazenado no storage, usado em emails personalizados)
- `brand_color_primary`/`brand_color_secondary`/`background_color`: formato hex `#XXXXXX`
- `required_guest_fields`: array de `passport`, `nationality`, `yellow_fever_vaccine`, `visa`, `rg` — campos opcionais por padrão no cadastro do convidado que se tornam obrigatórios apenas para este evento. **`rg` é opcional por default no perfil**; inclua-o aqui se o evento exigir documento de identidade brasileiro.
- `required_guest_document_uploads`: array (cada valor deve estar em `required_guest_fields`)
- `destinations`: array de `{ city, country, arrival_date?, departure_date? }` ou `null`
- `custom_guest_fields`: array de `{ key, label, type, required, options? }` ou `null`
- `terms_and_conditions`: string, máx. 10.000 caracteres ou `null`
- `invitation_email_text`: string, texto personalizado para o corpo do e-mail de convite ou `null` (usa texto padrão)
- `invitation_whatsapp_text`: string, texto personalizado para mensagem WhatsApp de convite ou `null`
- `invitation_preferred_channel`: enum — `email`, `whatsapp`, `both` ou `null` (default: `email`)
- `status`: enum — `upcoming`, `ongoing`, `completed`, `cancelled` (ciclo de vida temporal do evento). **`cancelled` só pode ser definido via API** (não há ação equivalente no painel do organizador). Quando cancelado, o evento permanece visível apenas para convidados já confirmados, com badge "Cancelado", até a `end_date`.
- `publish_status`: enum — `draft` (padrão) ou `published`. Controla a visibilidade do evento no app para convidados comuns. Ver seção "Ciclo de vida e visibilidade no app" acima.


#### Atualizar Evento
```http
PUT /events/{id}
Content-Type: application/json

{
  "name": "Novo Nome",
  "status": "ongoing"
}
```

#### Cancelar Evento (exclusivo da API)

```http
PUT /events/{id}
Content-Type: application/json

{
  "status": "cancelled"
}
```

**Exclusivo da API** — o painel do organizador não expõe essa ação.

**Efeitos no app:**
- Aparece com badge **"Cancelado"** apenas para convidados já `confirmed` ou `checked_in`.
- Convidados ainda `pending` deixam de ver o evento na listagem.
- Permanece visível até `end_date` (ou `start_date` quando não houver `end_date`); depois é ocultado.
- O cron horário `update-event-status` **ignora eventos cancelados** — não há transição automática de volta para `upcoming`/`ongoing`/`past`. Use **Reativar Evento** para sair desse estado.

**Resposta 200:**
```json
{
  "event": {
    "id": "uuid-evento",
    "status": "cancelled",
    "updated_at": "2026-06-16T14:00:00Z"
  }
}
```

#### Reativar Evento

```http
PUT /events/{id}
Content-Type: application/json

{
  "status": "upcoming"
}
```

Aceita também `ongoing` ou `completed`. Após a reativação, o cron `update-event-status` volta a normalizar o status automaticamente em função de `start_date`/`end_date`, e a visibilidade do evento volta ao comportamento padrão (todos os convidados confirmados/pendentes voltam a vê-lo conforme as regras normais).

**Resposta 200:**
```json
{
  "event": {
    "id": "uuid-evento",
    "status": "upcoming",
    "updated_at": "2026-06-16T14:05:00Z"
  }
}
```

#### Deletar Evento
```http
DELETE /events/{id}
```

---

### Guests (Convidados)

Endpoints unificados que retornam tanto convidados registrados quanto pendentes.

#### Listar Convidados
```http
GET /guests?event_id={event_id}
```

#### Criar Convidado
```http
POST /guests
Content-Type: application/json

{
  "event_id": "uuid-do-evento",
  "email": "convidado@email.com",
  "full_name": "Nome do Convidado",
  "phone": "+5511999999999",
  "role": "guest",
  "status": "pending",
  "cpf": "12345678909",
  "passport_number": "AB123456",
   "external_id": "ERP-123",
   "nfc_uid": "04:A2:B3:C4:D5:E6:F7",
   "companions_allowed": 2,
   "send_notification": true,
   "custom_message": "Seja bem-vindo ao nosso evento!"

}
```

**Campos obrigatórios:** `event_id`, `email`

**`companions_allowed`** (opcional, inteiro 0–50, default `0`): número máximo de acompanhantes que **o próprio convidado** poderá cadastrar pelo app. Aceito também em `PUT /guests/{id}`. Pode ser consultado em `GET /guests/{id}`. Os acompanhantes em si continuam sendo criados via `POST /companions` (organizador) ou pelo próprio convidado autenticado.


**Busca de usuário (prioridade):** CPF → Passaporte → Email → Novo pending guest

**Roles:** `guest`, `buyer`, `supplier`, `organizer`

**Status:** `pending`, `confirmed`, `declined`, `checked_in`, `cancelled`

> **Nota sobre `declined`:** O status `declined` é definido quando o convidado recusa o convite pelo app (não via API). A data da recusa é registrada em `declined_at`. O convidado pode reverter a decisão a qualquer momento. A API aceita apenas `pending`, `confirmed`, `checked_in`, `cancelled` no campo `status`.

> **Nota sobre `supplier`:** O perfil `supplier` (fornecedor) tem o mesmo acesso de um convidado comum, porém pode receber atribuições de **controle de uso de voucher** pelo organizador. Quando atribuído, o fornecedor enxerga apenas os vouchers designados e os convidados vinculados a esses vouchers, podendo executar check-in/check-out, escanear QR/NFC e enviar notificações restritas aos participantes desses vouchers — tudo com suporte offline.

| Permissão | guest | buyer | supplier | organizer |
|-----------|:-----:|:-----:|:--------:|:---------:|
| Visualizar evento, agenda, vouchers próprios | ✅ | ✅ | ✅ | ✅ |
| Responder perguntas do Q&A | ❌ | ✅ | ❌ | ✅ |
| Acessar dashboard/analytics | ❌ | ✅ | ❌ | ✅ |
| Fazer check-in de convidados | ❌ | ❌ | ❌ | ✅ |
| Controlar vouchers (baixa/reativar) | ❌ | ❌ | ✅ (somente designados) | ✅ |
| Notificar convidados | ❌ | ❌ | ✅ (somente do voucher designado) | ✅ |

##### Campos de Perfil Opcionais

Os seguintes campos podem ser enviados diretamente no body do `POST /guests`. Para **usuários existentes**, são aplicados diretamente ao perfil. Para **pending guests**, são armazenados em `profile_data` (JSONB) e sincronizados automaticamente quando o convidado se cadastrar.

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `birth_date` | string (ISO date) | Data de nascimento |
| `rg` | string | RG (documento brasileiro) |
| `is_foreigner` | boolean | Se é estrangeiro |
| `nationality` | string | Nacionalidade |
| `dietary_restrictions` | string | Restrições alimentares |
| `mobility_restrictions` | string | Restrições de mobilidade |
| `address_street` | string | Logradouro |
| `address_number` | string | Número |
| `address_complement` | string | Complemento |
| `address_neighborhood` | string | Bairro |
| `address_city` | string | Cidade |
| `address_state` | string | Estado |
| `address_zip_code` | string | CEP |
| `address_country` | string | País |
| `passport_issue_date` | string (ISO date) | Data de emissão do passaporte |
| `passport_expiry_date` | string (ISO date) | Data de validade do passaporte |
| `yellow_fever_vaccine_date` | string (ISO date) | Data da vacina de febre amarela |
| `visa_number` | string | Número do visto |
| `visa_expiry_date` | string (ISO date) | Validade do visto |
| `visas` | array | Múltiplos vistos: `[{ "country": "...", "expiry_date": "..." }]` |
| `preferred_language` | string | Idioma preferido: `pt`, `en`, `es` |
| `custom_data` | object | Dados personalizados definidos pelo evento (ver `custom_guest_fields`) |

##### Resposta do POST /guests

A resposta varia conforme o cenário de identificação do convidado:

**Cenário 1 — Usuário existente encontrado (201):**
```json
{
  "guest": {
    "id": "uuid-guest-record",
    "event_id": "uuid-do-evento",
    "user_id": "uuid-do-usuario",
    "status": "confirmed",
    "role": "guest",
    "external_id": "ERP-123",
    "profiles": {
      "full_name": "João Silva",
      "email": "joao@email.com",
      "..."
    }
  },
  "credential": {
    "qr_code_data": "{\"guestId\":\"uuid\",\"eventId\":\"uuid\",\"name\":\"João Silva\",\"email\":\"joao@email.com\"}",
    "qr_code_url": "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=..."
  },
  "matched_by": "cpf",
  "pending_registration": false,
  "notification_sent": true
}
```

**Cenário 2 — Novo pending guest (201):**
```json
{
  "pending_guest": {
    "id": "uuid-pending",
    "email": "maria@email.com",
    "full_name": "Maria Santos",
    "event_id": "uuid-do-evento",
    "role": "guest",
    "status": "pending",
    "profile_data": { "birth_date": "1990-05-15", "..." }
  },
  "credential": {
    "qr_code_data": "{\"pendingGuestId\":\"uuid\",\"eventId\":\"uuid\",\"name\":\"Maria Santos\",\"email\":\"maria@email.com\",\"pending\":true}",
    "qr_code_url": "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=..."
  },
  "pending_registration": true,
  "notification_sent": false,
  "message": "Guest invitation created. User will be linked to event when they register with this email."
}
```

**Cenário 3 — Convidado já convidado (200):**
```json
{
  "guest": { "..." },
  "credential": { "..." },
  "already_invited": true,
  "pending_registration": false,
  "notification_sent": false
}
```

> **Nota:** Quando `already_invited: true`, o código HTTP retornado é **200** (ao invés de 201) e os dados do convite existente são preservados.

**Campos da resposta:**

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `guest` ou `pending_guest` | object | Dados do convidado (registrado ou pendente) |
| `credential` | object | Dados de credenciamento para uso imediato |
| `credential.qr_code_data` | string | JSON serializado para scanner do app |
| `credential.qr_code_url` | string | URL da imagem PNG do QR Code |
| `matched_by` | string | Como o usuário foi identificado: `cpf`, `passport`, `email` (apenas para usuários existentes) |
| `pending_registration` | boolean | `true` se o convidado precisa se cadastrar no app |
| `already_invited` | boolean | `true` se já havia convite para este evento |
| `notification_sent` | boolean | Se a notificação/email de convite foi enviada |
| `data_conflicts` | object | Campos enviados que diferem do perfil existente (quando aplicável) |

#### Atualizar Convidado
```http
PUT /guests/{id}
Content-Type: application/json

{
  "status": "confirmed",
  "role": "supplier",
  "nfc_uid": "04:A2:B3:C4:D5:E6:F7",
  "birth_date": "1990-05-15",
  "nationality": "Brasileiro(a)"
}
```

**Campos permitidos (evento):** `role`, `status`, `nfc_uid`, `external_id`
**Campos permitidos (perfil):** `full_name`, `phone`, `email`, `cpf`, `passport_number`, `birth_date`, `rg`, `is_foreigner`, `passport_issue_date`, `passport_expiry_date`, `nationality`, `dietary_restrictions`, `mobility_restrictions`, `address_*`, `yellow_fever_vaccine_date`, `visa_number`, `visa_expiry_date`, `visas`

#### Deletar Convidado
```http
DELETE /guests/{id}
```

---

### Pending Guests (Convidados Pendentes)

> **Nota:** Convidados pendentes são criados automaticamente via `POST /guests` quando o email informado não corresponde a nenhum usuário cadastrado. Os endpoints abaixo servem apenas para **consulta e exclusão** de registros pendentes.

```http
GET /pending-guests?event_id={event_id}
GET /pending-guests/{id}
DELETE /pending-guests/{id}
```

> **`POST /pending-guests` retorna `405 Method Not Allowed`** com instrução clara para usar `POST /guests` com `email` (e opcional `external_id`); um registro `pending_guest` é criado e auto-vinculado quando o usuário se cadastra.

> **Auto-vínculo com verificação de contato (v5.10.0):** O vínculo do `pending_guest` a uma conta de usuário só acontece quando o canal usado no match (`email` ou `phone`) estiver verificado no perfil do usuário. CPF e passaporte vinculam diretamente. Se o match for por canal não-verificado, é criado um *hold* interno e uma notificação no app pedindo a verificação. Ao verificar, o vínculo é resolvido automaticamente.
>
> O mesmo matching é reexecutado quando você atualiza o convite via `PUT /guests/{id}` adicionando/alterando `email`, `phone`, `cpf` ou `passport_number` — útil para corrigir contatos depois do envio inicial.

---


### Profiles (Dados Pessoais)

#### Atualizar Perfil
```http
PUT /profiles/{user_id}
Content-Type: application/json

{
  "full_name": "Nome Completo",
  "email": "novo@email.com",
  "phone": "+5511999999999",
  "cpf": "12345678909",
  "custom_data": { "crm": "12345-SP", "gender": "Masculino" }
}
```

> **Sobre `custom_data`:** Este campo armazena dados personalizados definidos pela configuração `custom_guest_fields` do evento (ex: CRM, sexo, empresa). Os valores são centralizados no perfil do usuário e reaproveitados entre eventos diferentes. As chaves devem corresponder ao `key` definido em `custom_guest_fields`.

#### Listar Perfis de um Evento
```http
GET /profiles?event_id={event_id}
```

#### Obter Perfil
```http
GET /profiles/{user_id}
```

#### Consultar Documentos Pendentes
```http
GET /profiles/missing-documents?event_id={event_id}
```

#### Buscar por External ID
```http
GET /profiles/by-external-id?external_id=ERP-123&event_id={event_id}
```

---

### Vouchers

#### Listar Vouchers
```http
GET /vouchers?event_id={event_id}
GET /vouchers?event_id={event_id}&reservation_status=pending_reservation
GET /vouchers?event_id={event_id}&declined=true
GET /vouchers?event_id={event_id}&permanently_declined=true
GET /vouchers?event_id={event_id}&pending_choice=true
GET /vouchers?event_id={event_id}&requires_guest_input=true
GET /vouchers?event_id={event_id}&limit=100&offset=0
```

**Paginação:** parâmetros `limit` (default `100`, máximo `500`) e `offset` (default `0`). A resposta inclui o objeto `pagination` e o header `X-Total-Count`:

```json
{
  "vouchers": [ /* ... */ ],
  "pagination": { "total": 1234, "limit": 100, "offset": 0, "has_more": true }
}
```

**Aliases aceitos em criação/atualização:** `valid_until` (alias de `valid_to`), `valid_from_date`, `valid_to_date`. Detalhes em `GET /schemas/vouchers`.



#### Obter Voucher
```http
GET /vouchers/{id}
```

Retorna o voucher com `voucher_guests` e `attachments`.

#### Criar Voucher
```http
POST /vouchers
Content-Type: application/json

{
  "event_id": "uuid-do-evento",
  "title": "Voo GRU → MIA - Ida",
  "description": "Passagem aérea São Paulo para Miami",
  "code": "LATAM-ABC123",
  "type": "flight",
  "valid_from": "2026-06-15T00:00:00Z",
  "valid_to": "2026-06-15T23:59:59Z",
  "guests": [
    { "user_id": "uuid-do-usuario-1" },
    { "guest_id": "uuid-do-event-guest" },
    { "pending_guest_id": "uuid-do-pending-guest" },
    { "external_id": "CRM-12345" },
    { "email": "joao@email.com" }
  ],
  "details": {
    "segments": [
      {
        "origin": "GRU",
        "destination": "MIA",
        "departure_date": "15/06/2026",
        "departure_time": "22:30",
        "arrival_time": "06:15",
        "airline": "LATAM",
        "flight_number": "LA8068",
        "duration": "9h45",
        "layovers": []
      }
    ],
    "class": "Executiva",
    "baggage": "2x23kg",
    "locator": "ABC123"
  }
}
```

**Campos obrigatórios:** `event_id`, `type`, `title`, `valid_from`, `valid_to`, `code` + (`guests[]` **ou** `user_id` **ou** `email`)

**Identificação do(s) convidado(s):**
- `guests`: **array de convidados** (formato principal). Cada item pode ter **qualquer um** destes identificadores:
  - `user_id` (UUID do usuário registrado)
  - `guest_id` (UUID do registro em `event_guests`)
  - `pending_guest_id` (UUID do registro em `pending_guests`)
  - `external_id` (string — identificador externo do convidado, ex.: ID do CRM/ERP)
  - `email` (registrado ou pendente)
- `user_id`: UUID do usuário (retrocompatível — cria 1 guest automaticamente)
- `email`: e-mail do convidado (retrocompatível — cria 1 guest automaticamente)

**Prioridade quando vários identificadores são enviados no mesmo item:**
`user_id > guest_id > pending_guest_id > external_id > email`

**Resolução de `external_id`** (escopo limitado ao `event_id` informado):
1. `event_guests.external_id` no evento
2. Tabela de aliases (preserva o vínculo após merges de conta / reconvites)
3. `pending_guests.external_id` no evento

> **Nota:** Identificadores que não puderem ser resolvidos retornam em `errors[]`; os demais são vinculados normalmente. Ao usar `email`, se o convidado ainda não estiver cadastrado, é armazenado como pendente (`pending_guest_email`) e vinculado automaticamente quando o convidado se cadastrar no app.

**Validações IATA (type `flight` e `pre_flight`):**
- `details.segments[].origin` e `details.segments[].destination`: código IATA de 3 letras (auto-convertido para maiúsculas)

**Tipos possíveis:** `flight`, `hotel`, `transport`, `meal`, `other`, `shirt`, `rental_car`, `leisure`, `ground_transport`, `insurance`, `pre_flight`, `pre_hotel`, `pre_rental_car`

**Status possíveis:** `active`, `used`, `expired`, `cancelled`

**Campos opcionais:**
- `requires_guest_input`: boolean — convidado deve preencher dados
- `requires_usage_control`: boolean — controle de utilização pelo organizador
- `reservation_notes`: string (máx. 5.000 caracteres) — instruções ou políticas
- `hotel_reservation_id`: UUID — vincula o voucher a uma reserva de hotel

#### Atualizar Voucher
```http
PUT /vouchers/{id}
Content-Type: application/json

{
  "status": "used",
  "reservation_status": "confirmed",
  "reservation_notes": "Reserva confirmada. Localizador: XYZ789.",
  "details": { "locator": "XYZ789", "seat": "14A" }
}
```

**Campos permitidos:** `title`, `description`, `valid_from`, `valid_to`, `code`, `status`, `type`, `details`, `requires_usage_control`, `requires_guest_input`, `reservation_status`, `reservation_notes`, `hotel_reservation_id`

**Status de reserva:** `pending_reservation`, `confirmed`, `cancelled`. Envie `null` em `reservation_status` para limpar o status; strings vazias são ignoradas.

#### Deletar Voucher
```http
DELETE /vouchers/{id}
```

#### Criar Vouchers em Lote
```http
POST /vouchers/bulk
Content-Type: application/json

{
  "vouchers": [
    {
      "event_id": "uuid-do-evento",
      "title": "Voo GRU → SDU",
      "code": "LATAM-001",
      "type": "flight",
      "valid_from": "2025-01-15T00:00:00Z",
      "valid_to": "2025-01-15T23:59:59Z",
      "guests": [{ "user_id": "uuid-1" }, { "email": "joao@email.com" }],
      "details": { "segments": [...] }
    }
  ]
}
```

**Limite:** máximo 100 vouchers por requisição

---

### Gerenciamento de Convidados do Voucher

#### Adicionar Convidados a um Voucher
```http
POST /vouchers/{id}/guests
Content-Type: application/json

{
  "guests": [
    { "user_id": "uuid-do-usuario" },
    { "email": "novo-convidado@email.com" }
  ]
}
```

#### Remover Convidado de um Voucher
```http
DELETE /vouchers/{id}/guests/{guestEntryId}
```

---

### Vouchers de Pré-Seleção (pre_flight, pre_hotel, pre_rental_car)

Vouchers que permitem ao convidado escolher entre múltiplas opções. Após a escolha, o voucher é convertido para o tipo correspondente (`flight`, `hotel`, `rental_car`) com `reservation_status: "pending_reservation"`.

```http
POST /vouchers
Content-Type: application/json

{
  "event_id": "uuid-do-evento",
  "title": "Escolha seu voo - São Paulo → Miami",
  "type": "pre_flight",
  "code": "PRE-FLIGHT-001",
  "valid_from": "2026-06-15T00:00:00Z",
  "valid_to": "2026-06-15T23:59:59Z",
  "guests": [{ "user_id": "uuid-do-usuario" }],
  "details": {
    "options": [
      {
        "id": "opt-1",
        "title": "Voo Manhã - LATAM LA8068",
        "description": "Saída às 06:30, chegada às 14:15",
        "details": {
          "segments": [
            {
              "origin": "GRU",
              "destination": "MIA",
              "departure_date": "15/06/2026",
              "departure_time": "06:30",
              "arrival_time": "14:15",
              "airline": "LATAM",
              "flight_number": "LA8068"
            }
          ]
        }
      },
      {
        "id": "opt-2",
        "title": "Voo Noite - AZUL AD 4567",
        "description": "Saída às 22:00, chegada às 06:15+1",
        "details": {
          "segments": [
            {
              "origin": "GRU",
              "destination": "MIA",
              "departure_date": "15/06/2026",
              "departure_time": "22:00",
              "arrival_time": "06:15",
              "airline": "AZUL",
              "flight_number": "AD 4567"
            }
          ]
        }
      }
    ]
  }
}
```

**Fluxo operacional:**
1. Convidado seleciona uma opção → voucher é convertido com `reservation_status: "pending_reservation"`
2. Operações consulta: `GET /vouchers?event_id={id}&reservation_status=pending_reservation`
3. Operações confirma: `PUT /vouchers/{id}` com `reservation_status: "confirmed"`

**Campos de controle (somente leitura):** `selected_option_id`, `rejection_reason`, `choice_made_at`, `declined_at`, `decline_reason`, `permanently_declined_at`, `permanent_decline_reason`

---

### Estrutura do Campo `details` por Tipo de Voucher

#### Tipo: `flight` (Aéreo)

Utiliza o array `segments[]` para representar todos os trechos do voo. Voos diretos possuem `layovers: []` (ou omitem o campo). Voos com escala incluem `layovers[]` no segmento correspondente.

```json
{
  "segments": [
    {
      "origin": "GRU",
      "destination": "MIA",
      "departure_date": "15/06/2026",
      "departure_time": "22:30",
      "arrival_time": "06:15",
      "airline": "LATAM",
      "flight_number": "LA8068",
      "duration": "9h45",
      "layovers": [
        {
          "airport": "SCL",
          "duration": "2h15",
          "flight_number": "LA531",
          "departure_time": "01:30",
          "arrival_time": "06:15"
        }
      ]
    }
  ],
  "class": "Executiva",
  "passengers": "1 adulto",
  "baggage": "2x23kg",
  "locator": "ABC123",
  "seat": "14A",
  "aircraft": "Boeing 787-9",
  "terminal": "3",
  "gate": "B12",
  "meal": "Refeição completa",
  "frequent_flyer": "LATAM Pass 123456",
  "check_in_url": "https://www.latamairlines.com/br/pt/check-in"
}
```

| Campo | Tipo | Obrigatório | Descrição |
|-------|------|-------------|-----------|
| `segments` | array | Sim | Lista de segmentos de voo |
| `segments[].origin` | string | Sim | **Código IATA** (3 letras) do aeroporto de origem |
| `segments[].destination` | string | Sim | **Código IATA** (3 letras) do aeroporto de destino |
| `segments[].departure_date` | string | | Data de partida |
| `segments[].departure_time` | string | | Horário de partida |
| `segments[].arrival_time` | string | | Horário de chegada |
| `segments[].airline` | string | | Companhia aérea |
| `segments[].flight_number` | string | | Número do voo |
| `segments[].duration` | string | | Duração do voo |
| `segments[].layovers` | array | Não | Conexões/escalas (omita para voo direto) |
| `segments[].layovers[].airport` | string | | Código IATA da conexão |
| `segments[].layovers[].duration` | string | | Tempo de conexão |
| `segments[].layovers[].flight_number` | string | | Voo do trecho seguinte |
| `class` | string | | Classe do voo |
| `baggage` | string | | Franquia de bagagem |
| `locator` | string | | Código localizador |
| `seat` | string | | Assento |
| `check_in_url` | string | | URL para check-in online |

> **Boas práticas:** Crie **um voucher para ida** e **outro para volta** para ordenação cronológica correta na timeline do app.

#### Tipo: `hotel` (Hospedagem — Informativo / Guest Input)

Use o tipo `hotel` via `POST /vouchers` para vouchers **informativos** ou com **preenchimento pelo convidado**. Para **gestão de quartos e rooming**, use `POST /hotel-reservations` com `rooms[]`.

```json
{
  "hotel_name": "Copacabana Palace",
  "address": "Av. Atlântica, 1702 - Copacabana, Rio de Janeiro",
  "check_in": "2026-06-15T15:00:00Z",
  "check_out": "2026-06-18T12:00:00Z",
  "room_type": "Suíte Deluxe Vista Mar",
  "guests": "2 adultos",
  "nights": "3 noites",
  "breakfast": "Café da manhã buffet incluso",
  "amenities": "Wi-Fi, Piscina, Spa, Academia",
  "reservation_code": "CP-789456",
  "phone": "+55 21 2548-7070",
  "floor": "8º andar",
  "wifi_password": "COPA2025VIP",
  "online_check_in_url": "https://hotel.com/checkin/CP-789456"
}
```

> **⚠️ Importante:** Use **formato ISO com horário** nos campos `check_in` e `check_out`.

#### Tipo: `transport` (Transfer)

```json
{
  "date": "2026-06-15",
  "pickup_time": "07:30",
  "pickup_location": "Terminal 8 - Saída A, Porta 3",
  "pickup_latitude": 40.6413,
  "pickup_longitude": -73.7781,
  "dropoff_location": "Hilton Midtown Manhattan",
  "dropoff_latitude": 40.7616,
  "dropoff_longitude": -73.9776,
  "vehicle": "Mercedes-Benz V-Class",
  "company": "NYC Executive Transfer",
  "driver_name": "Michael Johnson",
  "driver_contact": "+1 212-555-0199",
  "estimated_duration": "45 minutos",
  "booking_reference": "TRF-98271"
}
```

> **⚠️ Obrigatório:** `details.date` (data do transfer, formato `YYYY-MM-DD`) e `details.pickup_time` (horário de embarque, ex: `"07:30"` ou ISO completo). Esses campos são usados para posicionamento na timeline.

#### Tipo: `meal` (Alimentação)

```json
{
  "restaurant": "The Palm Court at The Plaza",
  "address": "768 5th Ave, New York, NY 10019",
  "date": "16/06/2026",
  "time": "20:00",
  "duration": "3 horas",
  "meal_type": "Jantar de Gala - Menu Degustação",
  "dress_code": "Black Tie",
  "wine_pairing": "Harmonização incluída",
  "open_bar": true,
  "table_reservation": "Mesa 12 - Salão Principal",
  "latitude": 40.7645,
  "longitude": -73.9744
}
```

#### Tipo: `shirt` (Camiseta / Kit)

```json
{
  "brand": "Nike",
  "collection": "Edição Limitada 2026",
  "color": "Verde e Amarelo",
  "material": "Dri-FIT Premium",
  "customization": "Nome bordado nas costas",
  "includes": "Camisa oficial + boné + pin exclusivo",
  "type_options": ["Tradicional", "Baby Look", "Polo"],
  "size_options": ["PP", "P", "M", "G", "GG", "XGG"],
  "pickup_location_options": ["Credenciamento A", "Credenciamento B"],
  "pickup_start_date": "2026-06-10",
  "pickup_start_time": "08:00",
  "pickup_end_date": "2026-06-14",
  "pickup_end_time": "18:00"
}
```

> Use com `requires_guest_input: true` e `requires_usage_control: true`. O QR Code de credenciamento é **sempre exibido** para vouchers de camiseta.

#### Tipo: `rental_car` (Carro Alugado)

```json
{
  "rental_company": "Localiza",
  "car_model": "Toyota Corolla",
  "car_category": "Sedan Intermediário",
  "pickup_location": "Aeroporto de Guarulhos - Loja 12",
  "pickup_latitude": -23.4356,
  "pickup_longitude": -46.4731,
  "dropoff_location": "Aeroporto Santos Dumont - Loja 3",
  "dropoff_latitude": -22.9104,
  "dropoff_longitude": -43.1631,
  "pickup_date": "2026-06-15",
  "pickup_time": "10:00",
  "dropoff_date": "2026-06-18",
  "dropoff_time": "10:00",
  "insurance": "Cobertura Total",
  "mileage_policy": "Km livre",
  "fuel_policy": "Devolver com tanque cheio",
  "reservation_code": "LOC-789456"
}
```

#### Tipo: `ground_transport` (Transporte Terrestre — Ônibus, Trem, Balsa, Van)

```json
{
  "origin": "Penn Station, New York",
  "origin_latitude": 40.7506,
  "origin_longitude": -73.9935,
  "destination": "Union Station, Washington DC",
  "destination_latitude": 38.8977,
  "destination_longitude": -77.0066,
  "departure_date": "2026-06-20",
  "departure_time": "08:00",
  "arrival_date": "2026-06-20",
  "arrival_time": "10:50",
  "company": "Amtrak",
  "vehicle_type": "Trem Acela Express",
  "seat_number": "Vagão 4, Assento 7B",
  "seat_class": "Business Class",
  "estimated_duration": "2h50",
  "booking_reference": "AMT-926481"
}
```

#### Tipo: `leisure` (Passeio / Lazer)

```json
{
  "activity_name": "City Tour Histórico",
  "location": "Centro Histórico",
  "address": "Praça XV de Novembro, s/n",
  "date": "16/06/2026",
  "time": "09:00",
  "duration": "3 horas",
  "meeting_point": "Lobby do Hotel",
  "included": "Transporte, guia bilíngue, água",
  "company_name": "Brasil Tours Ltda",
  "guide_name": "Carlos Mendes",
  "contact_phone": "+55 21 99999-0000",
  "dress_code": "Roupas confortáveis",
  "latitude": -22.9068,
  "longitude": -43.1729
}
```

#### Tipo: `insurance` (Seguro Viagem)

```json
{
  "provider": "Allianz Travel",
  "policy": "AT-2026-BF001",
  "coverage": "USD 250.000",
  "emergency_phone": "+1 800 555 0199",
  "plan_type": "Platinum Internacional",
  "coverage_details": "Médica, odontológica, bagagem, cancelamento",
  "notes": "Apresentar apólice na imigração"
}
```

> O período de cobertura é definido por `valid_from`/`valid_to`. Use `reservation_notes` para instruções detalhadas e `POST /vouchers/{id}/attachments` para apólices em PDF.

#### Tipo: `other` (Outros)

Aceita qualquer estrutura de objeto JSON para vouchers genéricos.

---

### Controle de Utilização de Vouchers

| `requires_usage_control` | Comportamento |
|--------------------------|---------------|
| `true` | Voucher aparece no dashboard do organizador, exibe QR Code para baixa |
| `false` | Voucher é apenas informativo, sem controle |

### Vouchers com Preenchimento pelo Convidado

Vouchers com `requires_guest_input: true` enviam notificação e lembretes diários ao convidado para preencher dados. O campo `guest_input_completed_at` é marcado após o preenchimento.

---

### Voucher Attachments (Anexos)

#### Listar Anexos
```http
GET /vouchers/{id}/attachments
```

#### Registrar Anexo
```http
POST /vouchers/{id}/attachments
Content-Type: application/json

{
  "file_name": "bilhete-aereo.pdf",
  "file_type": "application/pdf",
  "file_size": 102400,
  "storage_path": "vouchers/uuid/bilhete-aereo.pdf",
  "uploaded_by": "uuid-do-organizador"
}
```

#### Deletar Anexo
```http
DELETE /vouchers/{id}/attachments/{attachmentId}
```

---

### Voucher Changes (Alterações)

```http
GET /vouchers/changes?event_id={event_id}
GET /vouchers/changes?event_id={event_id}&since=2025-01-20T00:00:00Z
GET /vouchers/changes?change_type=choice_made
```

**Tipos de alteração:** `created`, `edited`, `choice_made`, `details_updated`, `status_changed`, `reservation_updated`, `declined`, `undeclined`, `permanently_declined`

> **Nota:** `declined`, `undeclined` e `permanently_declined` são emitidos a partir de mudanças em `voucher_guests` (ação do convidado pelo app). Os demais tipos vêm da tabela `vouchers`.

---

### Reservas de Hotel (Hotel Reservations)

As reservas de hotel agrupam múltiplos vouchers de quarto sob uma mesma reserva. Suportam criação de quartos com convidados pré-alocados e controle de capacidade.

#### Listar Reservas de Hotel
```http
GET /hotel-reservations?event_id={event_id}
GET /hotel-reservations?event_id={event_id}&format=rooming-list
```

O parâmetro `format=rooming-list` retorna dados enriquecidos com perfis de convidados.

#### Obter Reserva de Hotel
```http
GET /hotel-reservations/{id}
```

#### Criar Reserva de Hotel
```http
POST /hotel-reservations
Content-Type: application/json

{
  "event_id": "uuid-do-evento",
  "title": "Reserva Copacabana Palace",
  "code": "CP-2026-001",
  "hotel_name": "Copacabana Palace",
  "check_in": "2026-06-15T15:00:00Z",
  "check_out": "2026-06-18T12:00:00Z",
  "total_rooms": 15,
  "details": {
    "address": "Av. Atlântica, 1702 - Copacabana, Rio de Janeiro",
    "phone": "+55 21 2548-7070",
    "email": "reservas@copacabanapalace.com",
    "website": "https://www.copacabanapalace.com",
    "reservation_code": "CP-789456",
    "online_check_in_url": "https://hotel.com/checkin/CP-789456",
    "wifi_network": "CopaGuest",
    "wifi_password": "COPA2025VIP",
    "breakfast": "Café da manhã buffet incluso (06h-10h30)",
    "amenities": "Wi-Fi, Piscina, Spa, Academia, Estacionamento",
    "check_in_policy": "Check-in a partir das 15h. Apresentar documento com foto.",
    "check_out_policy": "Check-out até 12h. Late check-out sujeito a disponibilidade.",
    "cancellation_policy": "Cancelamento gratuito até 48h antes do check-in.",
    "notes": "Hóspedes do grupo têm acesso ao lounge executivo."
  },
  "rooms": [
    {
      "title": "Quarto 301 - Suíte Deluxe",
      "code": "CP-301",
      "max_occupancy": 3,
      "room_type": "Suíte Deluxe Vista Mar",
      "description": "Suíte com varanda e vista para a praia de Copacabana.",
      "details": {
        "floor": "3º andar",
        "bed_configuration": "1 cama king + 1 sofá-cama",
        "breakfast": "Buffet incluso",
        "view": "Vista mar",
        "smoking": false,
        "amenities": "Cofre, frigobar, ar-condicionado, TV 55\""
      },
      "guests": [
        { "user_id": "uuid-do-usuario-1" },
        { "email": "acompanhante@email.com" }
      ]
    },
    {
      "title": "Quarto 302 - Standard",
      "code": "CP-302",
      "max_occupancy": 2,
      "room_type": "Standard",
      "details": {
        "floor": "3º andar",
        "bed_configuration": "2 camas de solteiro",
        "view": "Vista cidade"
      },
      "guests": []
    },
    {
      "title": "Quarto 303 - Standard",
      "code": "CP-303",
      "room_type": "Standard"
    }
  ]
}
```

**Campos obrigatórios:** `event_id`, `title`

**Campos opcionais (raiz):** `code`, `hotel_name`, `check_in` (ISO), `check_out` (ISO), `details` (JSONB livre), `total_rooms` (integer), `rooms` (array), `rooming_config`, `rooming_status`

**Campo `details` da reserva** (JSONB livre — paridade com voucher tipo `hotel`):

Use o `details` da reserva para armazenar **todas as informações do hotel** que devem aparecer nos vouchers de quarto gerados. Campos sugeridos (todos opcionais):

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `address` | string | Endereço completo do hotel |
| `phone` | string | Telefone de contato do hotel |
| `email` | string | E-mail de reservas |
| `website` | string | Site oficial |
| `reservation_code` | string | Código/localizador da reserva no hotel |
| `online_check_in_url` | string | URL para web check-in |
| `wifi_network` | string | Nome da rede Wi-Fi |
| `wifi_password` | string | Senha do Wi-Fi |
| `breakfast` | string | Informações sobre café da manhã (horário, formato, inclusão) |
| `amenities` | string | Comodidades do hotel (piscina, academia, spa, etc.) |
| `check_in_policy` | string | Política e horário de check-in |
| `check_out_policy` | string | Política e horário de check-out |
| `cancellation_policy` | string | Política de cancelamento |
| `notes` | string | Observações e instruções gerais |

> Como `details` é JSONB livre, você pode adicionar qualquer outro campo que faça sentido para o hotel (ex: `parking`, `pets_allowed`, `concierge`, `transfer_included`, etc.).

**Campo `rooms[]`** (opcional, aceito tanto em POST quanto em PUT — em PUT é aditivo):

| Campo | Tipo | Obrigatório | Descrição |
|-------|------|-------------|-----------|
| `title` | string | Sim | Nome do quarto |
| `code` | string | Não | Código do quarto (auto-gerado se omitido) |
| `max_occupancy` | integer | Não | Capacidade máxima do quarto (default: `rooming_config.max_guests_per_room`) |
| `room_type` | string | Não | Categoria do quarto (ex: "Standard", "Suíte Deluxe", "Duplo") |
| `check_in` | ISO 8601 | Não | Check-in **próprio** deste quarto. Quando informado, sobrescreve o `check_in` da reserva apenas para este quarto. **Exige `check_out` e `guests[]`.** |
| `check_out` | ISO 8601 | Não | Check-out próprio deste quarto. Deve ser posterior a `check_in`. |
| `description` | string | Não | Descrição do quarto |
| `details` | object | Não | Dados adicionais do quarto (JSONB livre) |
| `guests` | array | Não* | Convidados pré-alocados (`{ user_id }` ou `{ email }`). *Obrigatório quando `check_in`/`check_out` são informados.* |

**Campo `details` do quarto** — campos sugeridos (todos opcionais):

| Campo | Tipo | Descrição |
|-------|------|-----------|
| `floor` | string | Andar do quarto |
| `bed_configuration` | string | Configuração das camas (ex: "1 king", "2 solteiros", "1 king + sofá-cama") |
| `view` | string | Vista do quarto (mar, cidade, jardim, interna) |
| `breakfast` | string | Café da manhã específico do quarto (sobrescreve o da reserva se presente) |
| `smoking` | boolean | Se permite fumantes |
| `accessibility` | string | Recursos de acessibilidade |
| `amenities` | string | Comodidades específicas do quarto (cofre, frigobar, etc.) |
| `notes` | string | Observações específicas do quarto |

- `max_occupancy` e `room_type` são automaticamente espelhados dentro do `details` do voucher gerado
- Quartos sem `max_occupancy` usam o valor global de `rooming_config.max_guests_per_room`
- Quartos com `guests[]` preenchido criam `voucher_guests` com `allocated_by: "api"`
- Quartos sem `guests` ficam disponíveis para alocação dinâmica via rooming
- `valid_from`/`valid_to` do voucher equivalem às datas efetivas do quarto: usam `check_in`/`check_out` do próprio quarto quando informados, caso contrário herdam da reserva
- Quartos com `check_in`/`check_out` próprios são gravados com `details.custom_check_in` e `details.custom_check_out` e **não participam da sugestão automática de rooming** (`suggest-rooming` ignora esses quartos; por isso exigem `guests[]`)
- Os campos do `details` da **reserva** ficam disponíveis para o app exibir junto com cada voucher de quarto (ex: senha do Wi-Fi, link de web check-in)

**Resposta (201):**
```json
{
  "hotel_reservation": {
    "id": "uuid-gerado",
    "event_id": "uuid-do-evento",
    "title": "Reserva Copacabana Palace",
    "total_rooms": 15,
    "..."
  },
  "rooms": [
    {
      "id": "uuid-voucher-301",
      "title": "Quarto 301 - Suíte Deluxe",
      "type": "hotel",
      "hotel_reservation_id": "uuid-gerado",
      "voucher_guests": [
        { "id": "uuid-vg-1", "user_id": "uuid-do-usuario-1", "allocated_by": "api", "status": "active" },
        { "id": "uuid-vg-2", "pending_guest_email": "acompanhante@email.com", "allocated_by": "api", "status": "active" }
      ]
    },
    {
      "id": "uuid-voucher-302",
      "title": "Quarto 302 - Standard",
      "type": "hotel",
      "hotel_reservation_id": "uuid-gerado",
      "voucher_guests": []
    }
  ]
}
```

#### Atualizar Reserva de Hotel
```http
PUT /hotel-reservations/{id}
Content-Type: application/json

{
  "title": "Reserva Atualizada",
  "total_rooms": 20,
  "check_in": "2026-06-16T15:00:00Z",
  "check_out": "2026-06-19T12:00:00Z",
  "rooming_config": {
    "max_guests_per_room": 2,
    "deadline": "2026-06-01T23:59:59Z",
    "instructions": "Escolha com quem deseja compartilhar o quarto.",
    "matching_rules": ["gender"]
  },
  "rooming_status": "collecting"
}
```

**Campos permitidos:** `title`, `code`, `hotel_name`, `check_in`, `check_out`, `details`, `total_rooms`, `rooming_config`, `rooming_status`

**`rooming_status`:** `draft` → `collecting` → `closed` → `allocated`

#### Deletar Reserva de Hotel
```http
DELETE /hotel-reservations/{id}
```

> **Atenção:** Deletar uma reserva **não** deleta os vouchers (quartos) vinculados.

---

### Rooming List Automatizado

Fluxo completo para distribuição automatizada de hóspedes em quartos:

1. **Configurar**: `PUT /hotel-reservations/:id` com `rooming_config` e `rooming_status: "collecting"`
2. **Coletar**: Convidados enviam preferências pelo app
3. **Consultar**: `GET /hotel-reservations/:id/rooming-preferences`
4. **Sugerir**: `POST /hotel-reservations/:id/rooming-suggest`
5. **Confirmar**: `POST /hotel-reservations/:id/rooming-confirm`
6. **Relatório**: `GET /hotel-reservations/:id/rooming-list`

> Quartos pré-alocados via API (`allocated_by: "api"`) são tratados como ocupados e ignorados pelo algoritmo de sugestão.

#### Listar Preferências
```http
GET /hotel-reservations/{id}/rooming-preferences
```

#### Gerar Sugestões
```http
POST /hotel-reservations/{id}/rooming-suggest
```

Prioriza: matches mútuos → matches unilaterais → agrupamento por regras.

#### Confirmar Alocações
```http
POST /hotel-reservations/{id}/rooming-confirm
Content-Type: application/json

{
  "allocations": [
    { "voucher_id": "uuid-quarto-301", "user_ids": ["uuid-1", "uuid-2"] },
    { "voucher_id": "uuid-quarto-302", "user_ids": ["uuid-3"] }
  ]
}
```

#### Consultar Rooming List
```http
GET /hotel-reservations/{id}/rooming-list
```

**Resposta:**
```json
{
  "hotel_reservation": {
    "id": "uuid-reserva",
    "hotel_name": "Copacabana Palace",
    "check_in": "2026-06-15T15:00:00Z",
    "check_out": "2026-06-18T12:00:00Z",
    "rooming_status": "allocated"
  },
  "rooms": [
    {
      "voucher_id": "uuid-quarto-301",
      "title": "Quarto 301 - Suíte Deluxe",
      "code": "CP-301",
      "guests": [
        {
          "voucher_guest_id": "uuid-vg-1",
          "user_id": "uuid-1",
          "status": "active",
          "allocated_by": "api",
          "profile": { "full_name": "João Silva", "email": "joao@email.com" }
        }
      ]
    }
  ],
  "summary": {
    "total_rooms": 15,
    "created_rooms": 10,
    "occupied_rooms": 8,
    "empty_rooms": 2,
    "total_guests": 16
  }
}
```

---

### Agenda (Programação)

#### Listar Itens
```http
GET /agenda?event_id={event_id}
```

#### Criar Item
```http
POST /agenda
Content-Type: application/json

{
  "event_id": "uuid-do-evento",
  "title": "Palestra de Abertura",
  "start_time": "2025-01-15T09:00:00Z",
  "end_time": "2025-01-15T10:00:00Z",
  "type": "session",
  "location": "Auditório Principal",
  "speakers": ["Dr. João Silva"],
  "description": "Abertura oficial do evento",
  "rich_description": "<h2>Abertura Oficial</h2><p>Detalhes...</p>",
  "capacity": 500,
  "requires_confirmation": true,
  "allow_qa": true
}
```

**Campos obrigatórios:** `event_id`, `title`, `start_time`, `end_time`

**Tipos:** `session`, `break`, `meal`, `networking`, `activity`, `workshop`, `keynote`, `panel`

#### Criar em Lote
```http
POST /agenda/bulk
Content-Type: application/json

{
  "agenda_items": [
    { "event_id": "uuid", "title": "Abertura", "start_time": "...", "end_time": "...", "type": "session" },
    { "event_id": "uuid", "title": "Coffee Break", "start_time": "...", "end_time": "...", "type": "break" }
  ]
}
```

#### Atualizar Item
```http
PUT /agenda/{id}
```

#### Deletar Item
```http
DELETE /agenda/{id}
```

#### Agenda Attachments
```http
GET /agenda/{id}/attachments
POST /agenda/{id}/attachments
DELETE /agenda/{id}/attachments/{attachmentId}
```

#### Agenda Messages (Q&A)
```http
GET /agenda/{id}/messages
POST /agenda/{id}/messages
```

---

### Companions (Acompanhantes)

```http
GET /companions?event_id={event_id}
GET /companions/{id}
POST /companions
DELETE /companions/{id}
DELETE /companions?companion_pending_guest_id={uuid}&event_id={uuid}
```

**Deletar vínculo por Pending Guest ID:**

Alternativa ao `DELETE /companions/{id}` para quando o sistema integrador possui apenas o `companion_pending_guest_id` em vez do ID do link:

```http
DELETE /companions?companion_pending_guest_id={uuid}&event_id={uuid}
```

| Parâmetro | Tipo | Obrigatório | Descrição |
|-----------|------|-------------|-----------|
| `companion_pending_guest_id` | UUID | Sim | ID do convidado pendente vinculado como acompanhante |
| `event_id` | UUID | Sim | ID do evento |

**Criar vínculo:**
```json
{
  "event_id": "uuid-do-evento",
  "primary_email": "pac@email.com",
  "companion_email": "acompanhante@email.com",
  "companion_name": "Maria Silva"
}
```

**Identificadores alternativos (primary e companion):**

Além de `primary_email` / `companion_email`, é possível identificar os convidados por:

| Campo | Descrição |
|---|---|
| `primary_user_id` / `companion_user_id` | UUID do usuário registrado |
| `primary_pending_guest_id` / `companion_pending_guest_id` | UUID do convidado pendente |
| `primary_external_id` / `companion_external_id` | ID externo do sistema parceiro |

Apenas **um** identificador é necessário para cada lado (primary e companion). A prioridade de resolução é: `user_id` → `pending_guest_id` → `external_id` → `email`.

**Exemplo com external_id:**
```json
{
  "event_id": "uuid-do-evento",
  "primary_external_id": "EXT-001",
  "companion_external_id": "EXT-002",
  "companion_name": "Maria Silva"
}
```

---

### Notifications (Notificações)

#### Enviar Notificação
```http
POST /notifications
Content-Type: application/json

{
  "event_id": "uuid-do-evento",
  "user_ids": ["uuid-1", "uuid-2"],
  "title": "Lembrete Importante",
  "message": "O evento começa em 1 hora!",
  "type": "alert",
  "image_url": "https://example.com/image.jpg",
  "channels": ["in_app", "push", "whatsapp", "email"],
  "actions": [
    { "type": "confirm_attendance", "label": "Confirmar Presença" },
    { "type": "open_event", "label": "Ver Evento" }
  ]
}
```

**Tipos:** `info`, `warning`, `success`, `alert`

> **Alias:** o campo `message` também aceita o nome `body` (compatibilidade com payloads de push de terceiros — OneSignal, FCM). Envie apenas um dos dois.


**Canais de entrega (`channels`)**

Campo opcional que controla por quais canais a notificação será entregue. Quando **omitido**, o comportamento padrão é criar apenas o aviso **in-app** (sininho dentro do app) — sem disparar push, WhatsApp ou e-mail.

Quando informado, deve ser um array não vazio contendo um ou mais dos valores:

| Valor      | O que faz |
|------------|-----------|
| `in_app`   | Cria o aviso dentro do app (aparece no sino e em `GET /notifications`). |
| `push`     | Envia push via OneSignal para os dispositivos do usuário (respeita a preferência do usuário). |
| `whatsapp` | Envia mensagem via template aprovado da Meta para o telefone cadastrado. |
| `email`    | Envia e-mail HTML com identidade visual do evento. |

**Regra de cascata (importante):**

Quando mais de um canal é selecionado, a entrega segue a cascata `push → whatsapp → email` **entre os canais escolhidos**, parando no primeiro que entregar com sucesso. O `in_app` é independente — sempre é criado quando incluído, sem afetar a cascata externa.

Exemplos:
- `["in_app","push","whatsapp","email"]` → cria o aviso, tenta push; se entregou, para. Se push não chegou (sem dispositivo, sem preferência, etc.), tenta WhatsApp; se falhar, cai para e-mail.
- `["push","email"]` → tenta push; se entregou, e-mail é pulado. Se push não chegou, envia e-mail.
- `["whatsapp","email"]` → tenta WhatsApp; se falhar, cai para e-mail.
- `["push"]` → só push, sem fallback nenhum.
- `["email"]` → só e-mail.
- `["in_app"]` → só o aviso no sino (mesmo comportamento de omitir o campo).

**Outras notas:**
- Se o usuário desabilitou a preferência correspondente (`event_updates`, `voucher_updates`, etc.), o push é pulado mesmo se solicitado — e nesse caso a cascata segue para o próximo canal escolhido.
- A entrega externa (push/whatsapp/email) é assíncrona (fire-and-forget). O status final de cada tentativa fica registrado em `notification_deliveries`.

**Resposta:**
```json
{
  "notifications": [{ "id": "...", "user_id": "...", ... }],
  "count": 2,
  "channels": ["in_app", "push", "whatsapp", "email"],
  "delivery_dispatched": 2
}
```

`delivery_dispatched` é a quantidade de usuários para os quais o pipeline de entrega multi-canal (push/WhatsApp/email) foi disparado. Vale `0` quando `channels` é omitido ou contém apenas `in_app`.

**Tipos de ação e seus payloads:**

| Tipo de Ação | Descrição | Payload Obrigatório |
|--------------|-----------|---------------------|
| `confirm_attendance` | Botão para confirmar presença no evento | Nenhum |
| `open_voucher` | Link direto para um voucher específico | `{ "payload": { "voucherId": "uuid" } }` |
| `open_agenda` | Link direto para um item da agenda | `{ "payload": { "agendaItemId": "uuid" } }` |
| `open_event` | Link para a página principal do evento | Nenhum |
| `link` | Link externo (abre no navegador) | `{ "payload": { "url": "https://..." } }` |

**Exemplo com payload:**
```json
{
  "actions": [
    {
      "type": "open_voucher",
      "label": "Ver Voucher",
      "payload": { "voucherId": "550e8400-e29b-41d4-a716-446655440000" }
    },
    {
      "type": "link",
      "label": "Ver Programação",
      "payload": { "url": "https://evento.com/programacao" }
    }
  ]
}
```

Se `user_ids` omitido, envia para todos os convidados (exceto `declined`).

#### Consultar Preferências de Notificação
```http
GET /notifications/preferences-stats?event_id={event_id}
```

---

### Notification Broadcasts (Agendados)

Notificações agendadas para envio futuro (processadas por cron job). Reaproveitam toda a engine de cascata (`in_app`/`push`/`whatsapp`/`email`) e ações de `/notifications`.

```http
POST   /notification-broadcasts
GET    /notification-broadcasts?event_id={event_id}&status=pending|sent|cancelled
DELETE /notification-broadcasts/{id}
```

**`POST` — agendar broadcast**
```json
{
  "event_id": "uuid-do-evento",
  "scheduled_at": "2026-06-15T14:00:00Z",
  "title": "Lembrete: programação amanhã",
  "message": "Confira sua agenda personalizada.",
  "type": "info",
  "user_ids": ["uuid-1", "uuid-2"],
  "channels": ["in_app", "push"],
  "actions": [
    { "type": "open_agenda", "label": "Ver agenda", "payload": { "agendaItemId": "uuid" } }
  ]
}
```

- `scheduled_at` deve ser **>= now + 1 minuto**.
- `user_ids` opcional (omitido = todos os convidados confirmados do evento).
- `DELETE` só funciona enquanto `status = pending` (retorna `409` se já enviado/cancelado).
- 15 minutos antes do envio, o organizador recebe uma notificação interna de lembrete.

---

### Schemas (Introspecção)

Endpoint somente-leitura para descobrir o contrato de payloads sem precisar consultar a documentação. Não consome scope `:write`.

```http
GET /schemas/vouchers
```

**Resposta (resumida):**
```json
{
  "voucher_types": ["flight", "hotel", "transport", "..." ],
  "top_level_required": ["event_id", "type", "title", "code", "valid_from", "valid_to", "guests"],
  "top_level_optional": ["description", "status", "requires_guest_input", "requires_usage_control", "details"],
  "guests_contract": {
    "description": "Array (max 500). Cada entrada deve incluir ao menos um identificador.",
    "accepted_identifiers": ["user_id", "guest_id", "pending_guest_id", "external_id", "email"],
    "example": [{ "user_id": "..." }, { "external_id": "CRM-12345" }, { "email": "guest@example.com" }]
  },
  "date_fields": {
    "valid_from": "ISO date/datetime. Pulado quando requires_guest_input=true ou type começa com pre_.",
    "valid_to": "ISO date/datetime, deve ser >= valid_from.",
    "alternative_keys": { "valid_from": ["valid_from_date"], "valid_to": ["valid_to_date", "valid_until"] }
  },
  "schemas": { "flight": { "required_details": [...], "alternative_keys": {...}, "example_minimal": {...} } }
}
```

---

### Rate Limits

Sliding window de 60s aplicado por `(client_id|ip, METHOD resource)` — autoritativo cross-isolate (DB-backed) + fast-path in-memory por isolate.

| Endpoint | Limite por minuto |
|---|---|
| `POST /guests` | 60 |
| `POST /vouchers` | 120 |
| `POST /hotel-reservations` | 60 |
| `POST /agenda` | 60 |
| `POST /notifications` | 30 |
| `POST /notification-broadcasts` | 30 |
| `POST /events` | 30 |
| Outras escritas (default) | 120 |
| Leituras (default) | 600 |

**Quando excedido — `429 Too Many Requests`:**
```json
{
  "error": "rate_limit_exceeded",
  "error_description": "Too many POST requests to /notifications. Retry in 24s.",
  "retry_after_seconds": 24
}
```
Header `Retry-After: <segundos>` também é enviado.

---

### Health Check


```http
GET /health
```

---

## Códigos de Resposta

| Código | Descrição |
|--------|-----------|
| 200 | Sucesso (ou convidado já convidado) |
| 201 | Criado com sucesso |
| 400 | Requisição inválida |
| 401 | Não autorizado (token ausente, expirado ou inválido) |
| 403 | Acesso negado (escopo insuficiente) |
| 404 | Recurso não encontrado |
| 405 | Método não permitido |
| 500 | Erro interno |

## Formato de Erro Padronizado

Todas as respostas de erro seguem um formato consistente:

**401 — Token inválido ou expirado:**
```json
{
  "error": "invalid_token",
  "error_description": "Token expired or invalid"
}
```

**403 — Escopo insuficiente:**
```json
{
  "error": "insufficient_scope",
  "error_description": "Required scope: write"
}
```

**400 — Validação de entrada:**
```json
{
  "error": "Validation failed: name is required, end_date must be after start_date"
}
```

**404 — Recurso não encontrado:**
```json
{
  "error": "Event not found"
}
```

**500 — Erro interno:**
```json
{
  "error": "Internal server error"
}
```

> **Nota:** Erros de autenticação OAuth retornam campos `error` e `error_description` conforme o padrão RFC 6749.

---

## Exemplos com cURL

### Obter Access Token

```bash
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"client_credentials","client_id":"SEU_CLIENT_ID","client_secret":"SEU_CLIENT_SECRET"}' \
  https://api-eventosapp.beflytech.com.br/api/v1/oauth-token
```

### Criar evento

```bash
curl -X POST \
  -H "Authorization: Bearer SEU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Tech Summit 2026",
    "description": "O maior evento de tecnologia",
    "location": "Centro de Convenções, São Paulo",
    "start_date": "2026-06-15T08:00:00Z",
    "end_date": "2026-06-15T20:00:00Z",
    "status": "upcoming",
    "brand_color_primary": "#0066cc",
    "brand_color_secondary": "#00aaff"
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/events
```

### Adicionar convidado

```bash
curl -X POST \
  -H "Authorization: Bearer SEU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "uuid-do-evento",
    "email": "convidado@email.com",
    "full_name": "Nome do Convidado",
    "phone": "+5511999999999",
    "role": "guest"
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/guests
```

### Criar voucher de voo com múltiplos convidados

```bash
curl -X POST \
  -H "Authorization: Bearer SEU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "uuid-do-evento",
    "title": "Voo GRU → MIA - Ida",
    "code": "LATAM-001",
    "type": "flight",
    "valid_from": "2026-06-15T00:00:00Z",
    "valid_to": "2026-06-15T23:59:59Z",
    "guests": [
      {"user_id": "uuid-1"},
      {"email": "joao@email.com"}
    ],
    "details": {
      "segments": [{
        "origin": "GRU",
        "destination": "MIA",
        "departure_date": "15/06/2026",
        "departure_time": "22:30",
        "arrival_time": "06:15",
        "airline": "LATAM",
        "flight_number": "LA8068"
      }],
      "class": "Executiva",
      "locator": "ABC123"
    }
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### Criar reserva de hotel com quartos pré-alocados

```bash
curl -X POST \
  -H "Authorization: Bearer SEU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "uuid-do-evento",
    "title": "Reserva Copacabana Palace",
    "hotel_name": "Copacabana Palace",
    "code": "CP-2026-001",
    "check_in": "2026-06-15T15:00:00Z",
    "check_out": "2026-06-18T12:00:00Z",
    "total_rooms": 15,
    "details": {
      "address": "Av. Atlântica, 1702 - Copacabana, Rio de Janeiro",
      "phone": "+55 21 2548-7070",
      "reservation_code": "CP-789456",
      "online_check_in_url": "https://hotel.com/checkin/CP-789456",
      "wifi_network": "CopaGuest",
      "wifi_password": "COPA2025VIP",
      "breakfast": "Café da manhã buffet incluso (06h-10h30)",
      "amenities": "Wi-Fi, Piscina, Spa, Academia",
      "check_in_policy": "Check-in a partir das 15h",
      "check_out_policy": "Check-out até 12h",
      "cancellation_policy": "Cancelamento gratuito até 48h antes"
    },
    "rooms": [
      {
        "title": "Quarto 301 - Suíte Deluxe",
        "code": "CP-301",
        "max_occupancy": 3,
        "room_type": "Suíte Deluxe Vista Mar",
        "details": {
          "floor": "3º andar",
          "bed_configuration": "1 cama king + sofá-cama",
          "view": "Vista mar"
        },
        "guests": [{"user_id": "uuid-1"}, {"email": "maria@email.com"}]
      },
      {
        "title": "Quarto 302 - Standard",
        "code": "CP-302",
        "max_occupancy": 2,
        "room_type": "Standard",
        "details": {"floor": "3º andar", "bed_configuration": "2 solteiros"},
        "guests": []
      }
    ]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/hotel-reservations
```

### Enviar notificação

```bash
curl -X POST \
  -H "Authorization: Bearer SEU_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "uuid-do-evento",
    "title": "Bem-vindos!",
    "message": "O evento começa em 30 minutos!",
    "type": "info"
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/notifications
```

---

## Exemplos de payload por tipo de voucher

Snippets mínimos de `POST /vouchers` por `type`. Em todos os exemplos, substitua `EVENT_ID` pelo UUID do evento, `GUEST_ID` pelo UUID do convidado e `SEU_TOKEN` pelo access token OAuth. Para validação completa do `details`, consulte `GET /schemas/vouchers`.

### `flight` — voo

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "flight",
    "title": "GRU → MIA — LATAM 8181",
    "valid_from": "2026-08-10T22:00:00-03:00",
    "valid_to":   "2026-08-11T06:30:00-04:00",
    "details": { "airline": "LATAM", "flight_number": "LA8181", "origin": "GRU", "destination": "MIA",
                 "departure_datetime": "2026-08-10T22:00:00-03:00",
                 "arrival_datetime":   "2026-08-11T06:30:00-04:00" },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `hotel` — hospedagem

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "hotel",
    "title": "Hotel Copacabana Palace",
    "valid_from": "2026-08-11T15:00:00-03:00",
    "valid_to":   "2026-08-15T11:00:00-03:00",
    "details": { "hotel_name": "Copacabana Palace", "address": "Av. Atlântica 1702, Rio de Janeiro",
                 "check_in":  "2026-08-11T15:00:00-03:00",
                 "check_out": "2026-08-15T11:00:00-03:00",
                 "room_type": "Deluxe Ocean View", "confirmation_code": "BR123ABC" },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `transport` / `ground_transport` — transfer

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "ground_transport",
    "title": "Transfer Aeroporto → Hotel",
    "valid_from": "2026-08-11T07:00:00-03:00",
    "valid_to":   "2026-08-11T08:00:00-03:00",
    "details": { "provider": "Limousine Express", "pickup_location": "GIG — Terminal 2",
                 "dropoff_location": "Copacabana Palace",
                 "pickup_datetime": "2026-08-11T07:00:00-03:00",
                 "vehicle_type": "Sedan executivo" },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `meal` — refeição

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "meal",
    "title": "Jantar de boas-vindas",
    "valid_from": "2026-08-11T20:00:00-03:00",
    "valid_to":   "2026-08-11T23:00:00-03:00",
    "details": { "restaurant_name": "Mee", "address": "Av. Atlântica 1702",
                 "datetime": "2026-08-11T20:00:00-03:00", "menu_type": "Degustação 5 tempos" },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `rental_car` — locação de carro

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "rental_car",
    "title": "Localiza — Chevrolet Onix",
    "valid_from": "2026-08-11T10:00:00-03:00",
    "valid_to":   "2026-08-15T10:00:00-03:00",
    "details": { "rental_company": "Localiza", "vehicle_model": "Chevrolet Onix",
                 "pickup_location": "GIG", "dropoff_location": "GIG",
                 "pickup_datetime":  "2026-08-11T10:00:00-03:00",
                 "dropoff_datetime": "2026-08-15T10:00:00-03:00",
                 "confirmation_code": "LOC987" },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `leisure` — passeio / experiência

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "leisure",
    "title": "City tour Rio histórico",
    "valid_from": "2026-08-12T09:00:00-03:00",
    "valid_to":   "2026-08-12T13:00:00-03:00",
    "details": { "activity_name": "City tour Rio histórico", "meeting_point": "Lobby do hotel",
                 "datetime": "2026-08-12T09:00:00-03:00", "duration_hours": 4 },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `insurance` — seguro viagem

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "insurance",
    "title": "Seguro viagem internacional",
    "valid_from": "2026-08-10T00:00:00-03:00",
    "valid_to":   "2026-08-16T23:59:59-03:00",
    "details": { "insurer": "Assist Card", "policy_number": "AC-9876543",
                 "coverage_amount_usd": 250000, "emergency_phone": "+55 11 3056-3000" },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `pre_flight` / `pre_hotel` / `pre_rental_car` — pré-seleção pelo convidado

Vouchers de pré-seleção criam um stub sem detalhes operacionais; o convidado preenche depois no app. Use `requires_guest_input: true`.

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "pre_flight",
    "title": "Pré-seleção de voo (ida)",
    "valid_from": "2026-08-10T00:00:00-03:00",
    "valid_to":   "2026-08-10T23:59:59-03:00",
    "requires_guest_input": true,
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

### `shirt` / `other` — itens livres

Sem campos obrigatórios em `details`; use chaves arbitrárias para descrever o item.

```bash
curl -X POST -H "Authorization: Bearer SEU_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "event_id": "EVENT_ID",
    "type": "shirt",
    "title": "Camiseta oficial do evento",
    "details": { "size": "M", "color": "Preto" },
    "guests": [{ "user_id": "GUEST_ID" }]
  }' \
  https://api-eventosapp.beflytech.com.br/api/v1/backoffice-api/vouchers
```

---


## Auditoria

Todas as operações são registradas com logs de auditoria contendo timestamp, Client ID, tipo de ação, recurso e detalhes relevantes.

### Histórico de alterações — `entity_change_log` (somente backoffice via service role)

Tabela append-only no banco compartilhado. Registra automaticamente, via triggers, toda criação, edição e exclusão de:

| `entity_type` | Origem |
|---|---|
| `voucher` | `vouchers` |
| `voucher_guest` | `voucher_guests` (inclui check-in/check-out de voucher por organizador/fornecedor) |
| `agenda_item` | `agenda_items` |
| `agenda_confirmation` | `agenda_confirmations` |
| `agenda_checkin` | `agenda_item_checkins` |
| `daily_checkin` | `daily_checkins` (check-in/check-out diário do evento) |
| `profile` | `profiles` |

**Esquema:**

| Coluna | Tipo | Descrição |
|---|---|---|
| `id` | uuid | PK |
| `entity_type` | text | ver tabela acima |
| `entity_id` | uuid | id da linha alterada |
| `event_id` | uuid \| null | null apenas para `profile` |
| `profile_id` | uuid \| null | preenchido quando `entity_type='profile'` |
| `action` | text | `create` \| `update` \| `delete` |
| `changed_fields` | text[] | campos alterados |
| `before_data` | jsonb \| null | linha completa antes (null em `create`) |
| `after_data` | jsonb \| null | linha completa depois (null em `delete`) |
| `diff` | jsonb | `{ "field": { "old": ..., "new": ... } }` |
| `sensitive_masked` | boolean | true quando campos sensíveis do perfil foram mascarados |
| `actor_user_id` | uuid \| null | `auth.uid()` no momento da operação |
| `actor_role` | text | `admin` \| `organizer` \| `supplier` \| `buyer` \| `guest` \| `system` |
| `actor_oauth_client_id` | uuid \| null | via GUC `app.oauth_client_id` |
| `source` | text | `app` \| `backoffice_api` \| `edge_function` \| `trigger` |
| `created_at` | timestamptz | |

**Segmentação por evento (RLS):**
- Admin global vê tudo.
- Organizadores enxergam apenas logs cujo `event_id` pertença a eventos em que são organizadores.
- Logs de `profile` aparecem para o organizador apenas quando o convidado pertence a algum evento dele.
- O app não tem acesso de leitura. Backoffice consulta via service role.

**Privacidade de perfil:**
Campos sensíveis (`cpf`, `rg`, `birth_date`, `nationality`, `phone`, passaporte, vistos, vacinas, endereço completo, restrições alimentares/mobilidade) só ficam visíveis no log se o convidado já tiver aceitado pelo menos um convite (`event_guests.status='confirmed'`). Caso contrário, esses campos aparecem como `"***"` e `sensitive_masked=true`.

**Exemplos de consulta (service role):**

```sql
-- Histórico completo de um evento
SELECT * FROM entity_change_log
WHERE event_id = '<event-uuid>'
ORDER BY created_at DESC
LIMIT 100;

-- Quem fez check-in/check-out de voucher
SELECT created_at, actor_user_id, actor_role, entity_id, diff
FROM entity_change_log
WHERE event_id = '<event-uuid>'
  AND entity_type = 'voucher_guest'
  AND (diff ? 'used_at' OR diff ? 'used_by')
ORDER BY created_at DESC;

-- Alterações de um perfil específico
SELECT * FROM entity_change_log
WHERE entity_type = 'profile' AND profile_id = '<user-uuid>'
ORDER BY created_at DESC;
```

> Tabelas legadas `voucher_changes`, `profile_change_audit_log` e `event_config_audit_log` foram removidas. Todos os novos registros vão para `entity_change_log`.

---

## Segurança

1. **Armazenamento de Credenciais**: Use variáveis de ambiente ou cofres de segredos.
2. **Rotação de Tokens**: Tokens expiram após 1 hora. Implemente renovação automática.
3. **Escopo Mínimo**: Solicite apenas os escopos necessários.
4. **Revogação**: Revogue tokens imediatamente quando não forem mais necessários.
5. **HTTPS**: Todas as requisições devem usar HTTPS.

---

## Notas

- Todos os timestamps devem estar no formato ISO 8601 (UTC)
- UUIDs são gerados automaticamente pelo sistema quando não fornecidos
- O campo `details` em vouchers aceita qualquer objeto JSON
- Strings são automaticamente sanitizadas (trim) antes de serem salvas
- Campos não permitidos em operações de UPDATE são ignorados

---

## Saúde da fila de notificações (M11/M12)

Endpoints para o backoffice observar a fila de entrega de notificações e re-enfileirar quem não foi alcançado, sem precisar acessar o dashboard.

### `GET /notification-health?event_id={uuid}`

Retorna estado consolidado da fila + entregas das últimas 24h. Tenancy: clientes OAuth só enxergam eventos próprios (`created_by_oauth_client_id`). Escopo: `notification-health:read` ou `read`.

**Resposta 200**
```json
{
  "event_id": "…",
  "event_name": "…",
  "saturation": "ok | warn | critical",
  "queue": {
    "pending": 1234,
    "processing": 12,
    "failed": 3,
    "done": 9876,
    "oldest_pending_age_seconds": 92,
    "by_priority": { "0": 4, "3": 1230 }
  },
  "unreached_recipients": 87,
  "deliveries_last_24h": {
    "push":     { "sent": 5400, "failed": 12 },
    "whatsapp": { "sent": 1820, "failed": 5 },
    "email":    { "sent": 640 }
  },
  "fetched_at": "2026-05-24T22:00:00Z"
}
```

`saturation` é calculado a partir de `pending` e `oldest_pending_age_seconds`:
- `critical` — `pending > 5000` ou idade > 15 min
- `warn` — `pending > 1500` ou idade > 5 min
- `ok` — caso contrário

### `POST /notifications/retry-unreached?event_id={uuid}`

Re-enfileira até 500 destinatários que aparecem em `event_unreached_recipients` (nenhuma entrega bem-sucedida em nenhum canal). Aplica jitter de 5 min se o total > 1000 (anti-thundering-herd). Escopo: `notifications:write` ou `write`.

**Resposta 202**
```json
{
  "event_id": "…",
  "unreached_total": 87,
  "enqueued": 87,
  "note": "Capped at 500 recipients per call. Re-run to process the remainder."
}
```

`note` só aparece quando o lote precisou ser truncado — repita a chamada até `unreached_total == enqueued`.

---

## Webhooks (Outbound Notifications)


A API envia notificações HTTP em tempo real para URLs do seu backoffice quando convidados realizam ações que mudam dados — eliminando a necessidade de polling.

**Base URL de gerenciamento:** `https://api-eventosapp.beflytech.com.br/webhook-management`

### Catálogo de eventos (21 tipos)

#### Convite e presença
| Evento | Quando dispara |
|---|---|
| `guest.confirmed` | Convidado confirma presença |
| `guest.declined` | Convidado recusa o convite |
| `guest.checked_in` | Check-in no evento (QR/NFC/manual) |
| `guest.daily_checked_in` | Check-in diário em evento de múltiplos dias |
| `guest.agenda_checked_in` | Check-in em sessão da agenda |

#### Perfil e documentos
| Evento | Quando dispara |
|---|---|
| `guest.profile_updated` | Atualização de CPF, RG, passaporte, telefone, restrições, idioma, endereço |
| `guest.document_uploaded` | Upload de passaporte, visto, certificado de vacina |
| `guest.document_deleted` | Remoção de documento |

#### Vouchers (todos os tipos, inclusive camiseta)
| Evento | Quando dispara |
|---|---|
| `voucher.guest_input_completed` | Convidado preenche dados (inclui **camiseta com `selected_size`** em `data.details`) |
| `voucher.details_updated` | Convidado altera dados após preenchimento inicial |
| `voucher.choice_made` | Convidado escolhe opção de pré-voucher (hotel/voo/carro) |
| `voucher.declined` | Convidado marca "não usarei este voucher" (reversível) |
| `voucher.permanently_declined` | Convidado recusa o voucher **definitivamente** (irreversível — voucher some do app, organização deve cancelar a reserva) |
| `voucher.marked_used` | Convidado marca voucher como usado (ex: camiseta retirada) |
| `voucher.reactivated` | Convidado reativa voucher de `used` para `active` |

#### Acompanhantes
| Evento | Quando dispara |
|---|---|
| `companion.profile_completed` | PAC preenche dados do acompanhante pela primeira vez |
| `companion.profile_updated` | Atualização posterior dos dados |

#### Agenda, rooming e networking
| Evento | Quando dispara |
|---|---|
| `agenda.confirmed` | Confirma presença em sessão da agenda |
| `agenda.cancelled` | Cancela confirmação |
| `rooming.preference_submitted` | Submete preferências de quarto e colegas |
| `contact.request_sent` | Solicitação de contato no networking |
| `contact.request_accepted` | Aceita solicitação de contato |

> **Anti-loop:** se a ação foi originada via API pelo seu próprio cliente OAuth, o webhook **não** é disparado para você (evita eco).

### Formato do payload

Todas as requisições são `POST application/json` com este envelope:

```json
{
  "id": "ad7b...",
  "event_type": "voucher.guest_input_completed",
  "occurred_at": "2026-04-27T14:23:11Z",
  "api_version": "1.0",
  "data": {
    "voucher_id": "uuid",
    "event_id": "uuid",
    "user_id": "uuid",
    "external_id": "ABC123",
    "voucher_type": "shirt",
    "title": "Camiseta oficial",
    "status": "active",
    "details": {
      "selected_size": "M",
      "color": "azul",
      "available_sizes": ["P","M","G","GG"]
    },
    "guest_input_completed_at": "2026-04-27T14:23:10Z"
  }
}
```

### Headers enviados

| Header | Descrição |
|---|---|
| `X-Webhook-Signature` | `sha256=<hex>` — HMAC-SHA256 do body com seu secret |
| `X-Event-Type` | Tipo do evento |
| `X-Delivery-Id` | ID único desta tentativa de entrega (muda em cada retry) |
| `X-Idempotency-Key` | **UUID estável da ocorrência do evento** — igual em todos os retries do mesmo evento. Use este header para deduplicação. |
| `X-Event-Id` | UUID do evento Be Invited associado |
| `User-Agent` | `BeInvited-Webhooks/1.0` |

> **Importante:** `X-Delivery-Id` muda a cada retry; `X-Idempotency-Key` é **estável** por ocorrência. Sempre use `X-Idempotency-Key` para evitar processar a mesma ação duas vezes. O mesmo valor também está disponível em `payload.idempotency_key`.

### Validação da assinatura (recomendada)

**Node.js**
```js
import crypto from 'crypto';
function verify(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
```

**PHP**
```php
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'])) http_response_code(401);
```

**Python**
```python
import hmac, hashlib
expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
hmac.compare_digest(expected, request.headers['X-Webhook-Signature'])
```

### Idempotência no backoffice (obrigatório)

O Be Invited garante **at-most-once enfileiramento** por ocorrência (índice único em `endpoint_id + idempotency_key`), mas a rede pode duplicar entregas: se seu servidor processar e responder 200 mas o ACK se perder, faremos retry. Para evitar duplicidade no seu lado, persista a chave de idempotência.

**Esquema sugerido** (qualquer banco):
```sql
CREATE TABLE webhook_processed (
  idempotency_key UUID PRIMARY KEY,
  event_type      TEXT NOT NULL,
  processed_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

**Node.js (Express + Postgres)**
```js
app.post('/webhooks/beinvited', async (req, res) => {
  const idemKey = req.header('X-Idempotency-Key');
  if (!idemKey) return res.status(400).send('missing idempotency key');

  const { rowCount } = await db.query(
    `INSERT INTO webhook_processed (idempotency_key, event_type)
     VALUES ($1, $2) ON CONFLICT DO NOTHING`,
    [idemKey, req.header('X-Event-Type')]
  );

  if (rowCount === 0) return res.sendStatus(200); // já processado

  await processEvent(req.body);
  res.sendStatus(200);
});
```

**C# / .NET (SQL Server)**
```csharp
var key = Request.Headers["X-Idempotency-Key"].ToString();
var inserted = await db.ExecuteAsync(@"
  IF NOT EXISTS (SELECT 1 FROM WebhookProcessed WHERE IdempotencyKey = @key)
    INSERT INTO WebhookProcessed (IdempotencyKey, EventType) VALUES (@key, @type)",
  new { key, type = Request.Headers["X-Event-Type"].ToString() });

if (inserted == 0) return Ok(); // duplicado, ignora
await ProcessEvent(payload);
return Ok();
```

### Política de retentativa

Seu endpoint deve responder **2xx** em até **10 segundos**. Caso contrário:

| Tentativa | Delay |
|---|---|
| 1 | imediato |
| 2 | +1 min |
| 3 | +5 min |
| 4 | +30 min |
| 5 | +2 h |
| 6 (final) | +12 h → `failed_permanent` |

### Controle de fila (interno Be Invited)

- Entregas processadas em lotes de 50 a cada 1 minuto via cron.
- Reserva atômica via `FOR UPDATE SKIP LOCKED`: múltiplas execuções concorrentes do dispatcher nunca pegam a mesma entrega.
- Estados: `pending` → `in_flight` → `delivered` (ou volta a `pending` com `next_retry_at` futuro, ou `failed_permanent` após 5 tentativas).

### API de gerenciamento (OAuth Bearer)

Use o mesmo token OAuth da API principal.

#### `POST /webhooks` — Criar endpoint
```json
{
  "url": "https://meu-backoffice.com/webhooks/beinvited",
  "event_types": ["guest.confirmed", "voucher.guest_input_completed"],
  "event_id_filter": null,
  "description": "Sync de presenças e vouchers",
  "is_active": true
}
```
**Resposta 201** inclui `secret` (mostrado **somente nesta resposta** — guarde com segurança).

- `GET /webhooks` — Listar endpoints do cliente
- `GET /webhooks/:id` — Detalhes de um endpoint
- `PATCH /webhooks/:id` — Atualizar (`url`, `event_types`, `event_id_filter`, `is_active`, `description`)
- `DELETE /webhooks/:id` — Remover endpoint
- `GET /webhooks/:id/deliveries` — Histórico das últimas 100 entregas
- `POST /webhooks/:id/redeliver/:deliveryId` — Reenviar uma entrega antiga
- `POST /webhooks/:id/test` — Disparar evento `webhook.test`

### Boas práticas

1. **Responda rápido (<1s)**: enfileire o processamento e responda 200 imediatamente.
2. **Deduplique sempre via `X-Idempotency-Key`** (não use `X-Delivery-Id` — ele muda em cada retry).
3. **HTTPS obrigatório**.
4. **Valide a assinatura** em produção.
5. **Filtros**: use `event_id_filter` para receber notificações de um evento específico.

---

## Histórico de Versões

| Versão | Data | Resumo |
|--------|------|--------|
| 5.12.0 | 2026-06 | **Histórico unificado** — tabela `entity_change_log` substitui `voucher_changes`/`profile_change_audit_log`/`event_config_audit_log`; cobre vouchers, agenda, check-ins e perfil; segmentação por evento; mascaramento de perfil pré-aceite |
| 5.7.0 | 2026-05 | **Notification health & retry-unreached** — observabilidade da fila e reenvio em massa de não alcançados (M11/M12) |
| 5.6.0 | 2026-04 | **Webhooks outbound** — 21 eventos, HMAC-SHA256, anti-loop, retry exponencial |
| 5.5.3 | 2026-05 | Correção de `PUT /vouchers/{id}` com webhook interno e reuso de payload completo |
| 5.5.0 | 2026-04 | Migração do domínio da API para `api-eventosapp.beflytech.com.br` |
| 5.4.0 | 2026-03 | Deletar vínculo de acompanhante por `pending_guest_id` |
| 5.3.0 | 2026-03 | Comunicações de convite (`invitation_email_text`, `invitation_whatsapp_text`, `invitation_preferred_channel`) |
| 5.2.0 | 2026-03 | Tipos de quarto com `max_occupancy` e `room_type` |
| 5.1.0 | 2026-03 | Reservas de hotel com `rooms[]` e `total_rooms` |
| 5.0.0 | 2026-03 | Vouchers multi-guest + hotel reservations |
| 4.11.0 | 2026-03 | LGPD: redação de dados pessoais |
| 4.10.0 | 2026-02 | Termos e condições do evento |
| 4.9.0 | 2026-02 | Campos personalizados (`custom_guest_fields`) |
| 4.8.0 | 2026-02 | Guia do destino com IA |
| 4.7.0 | 2026-02 | Recusa de atividades (decline) |
| 4.6.0 | 2026-02 | Campos de perfil completos em guests/profiles |
| 4.5.0 | 2026-01 | Unificação guests + external_id + origin_id |
| 4.4.0 | 2026-01 | Fluxo operacional de reservas |
| 4.3.0 | 2026-01 | Voucher de seguro viagem |
| 4.2.0 | 2026-01 | Observações da reserva |
| 4.1.0 | 2026-01 | Validação IATA |
| 4.0.0 | 2026-01 | Sistema de acompanhantes |
| 3.6.0 | 2026-01 | Busca por CPF/passaporte |
| 3.5.0 | 2025-12 | Notificação multicanal |
| 3.4.0 | 2025-12 | Upload e análise de documentos |
| 3.3.0 | 2025-12 | Integração OneSignal |
| 3.2.0 | 2025-12 | Vouchers para pending guests |
| 3.1.0 | 2025-11 | Preferências de notificação |
| 3.0.0 | 2025-11 | Campos obrigatórios de documentos |
| 2.9.0 | 2025-11 | NFC para convidados |
| 2.8.0 | 2025-10 | Feed de alterações de vouchers |
| 2.7.0 | 2025-10 | Tipo leisure |
| 2.6.0 | 2025-10 | Pending guests (breaking change) |
| 2.1.0 | 2025-09 | Ações em notificações |
| 2.0.0 | 2025-09 | Migração para OAuth 2.0 |
