---
id: MOD-PUBLICACOES-AGENDA-DADOS
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-PUBLICACOES-AGENDA]
fontes:
  - V2/Modules/Publicacoes/Agenda/
depende_de: [CORE-DATA, SVC-SEARCH, SVC-FEEDRSS, SVC-GALLERY, SVC-ACTIVITYLOGGER]
consumidores: [MOD-LAYOUTS-HOMEPAGE, SVC-DASHBOARD, SVC-SEO, SVC-SEARCH, SVC-FEEDRSS]
criticidade: media
dono: Agenda
---

# Agenda — dados, modelo e hooks

> Schema real de `v2_eventos`, o model `Evento` (casts, slug, scopes), os hooks que
> escrevem em tabelas de outros serviços e as invariantes de data/publicação. O
> comportamento de tela e HTTP está em [`spec.md`](spec.md), [`cms.md`](cms.md) e
> [`portal.md`](portal.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Persiste eventos em `v2_eventos`, deriva slug único e, a cada escrita, sincroniza índice de busca e feed RSS. |
| Entrada | Atributos do model (`title`, datas, textos, `topics_list`, `schedule`, `is_active`, `published_at`, `image_id`) e scopes de consulta. |
| Saída | Linha em `v2_eventos`; UPSERT/remoção em `v2_public_search_index` e `v2_feed_rss_items`; registro em `activity_log`. |
| Efeitos colaterais | Logs `[Evento] Hook ...`; soft delete; `metadata` e `created_by` nunca persistidos. |
| Particularidade central | `published_at` e `is_active` são eixos independentes, e cada consumidor usa um subconjunto diferente deles. |
| Como se integra | `V2\Modules\Publicacoes\Agenda\Models\Evento` (`v2_eventos`), scopes estáticos e métodos `getImageUrl`/`getImageVariants`/`getPublicUrl`/`syncToSearch`. |
| Se removido, quebra | Homepage (`dynamic-events`, `sucesu-agenda`), dashboard ("Eventos Futuros"), sitemap, índice público e feed RSS. |
| Superfície pública | `Evento` + scopes `published`, `active`, `upcoming`, `upcomingOrdered`, `recent`, `forDate`, `forWeek`, `forMonth`, `weekBounds`. |

## 1. Efeito

### 1.1 O que o usuário consegue fazer

- Gravar um evento com data de início obrigatória e até 13 campos opcionais (CMS).
- Consultar por data, semana, mês ou futuro, com ou sem termo de busca (portal e consumidores).
- Recuperar imagem e variantes responsivas a partir de `image_id` da galeria.
- Arquivar (soft delete) sem apagar o histórico.

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| `INSERT`/`UPDATE` em `v2_eventos` | `Evento::save()` | `Models/Evento.php:67-159` |
| `id` UUID v4 quando ausente | hook `creating` | `Models/Evento.php:71-81`; `V2/Core/Helpers/helpers.php:61-69` |
| `slug` derivado do título e deduplicado | hooks `creating`/`saving` | `Models/Evento.php:76-99` |
| Upsert/remoção no índice público | hook `saved` → `syncToSearch` | `Models/Evento.php:104-112,221-243` |
| Upsert/remoção no feed RSS | hook `saved` | `Models/Evento.php:114-139` |
| Remoção de índice e feed no soft delete | hook `deleted` | `Models/Evento.php:142-158` |
| `deleted_at` preenchido | `destroy` | `EventosCmsController.php:272` |

### 1.3 Contrato observável

| Entrada | Resultado |
|---|---|
| `new Evento(['title' => 'X'])` + `save()` | Linha com `id` UUID, `slug='x'`, `is_active` conforme default do banco (`true`), `published_at` nulo (rascunho). |
| Título `"X"` já existente | `slug='x-1'`; na terceira vez `x-2`. |
| `is_active=false` no save | Remove do índice público e do feed. |
| `delete()` | `deleted_at` preenchido; scopes padrão deixam de retornar; busca/feed removidos. |
| `slug` vazio e `title` vazio | `str_slug('')` → string vazia; `slug` único pode falhar na segunda ocorrência (`QueryException`). |
| `event_date_start` nulo | Violação `NOT NULL` no banco (a validação do CMS impede antes). |
| `schedule` como string JSON | Cast `array` tenta `json_decode`; string inválida vira `null`/erro de cast `[I]`. |

### 1.4 O que este modelo NÃO faz

- Não valida nada: validação vive no controller (`EventosCmsController.php:79-95`).
- Não grava `metadata` (coluna inexistente) nem `created_by`.
- Não expõe relação com usuário/autor.
- Não mantém histórico de versões (só `activity_log` com descrição e título).
- Não restaura soft delete.
- Não atualiza `alt_text` da galeria.

## 2. Abordagem

### 2.1 Estratégia em uma página

O model é "gordo" em comportamento de leitura (10 scopes/métodos) e magro em escrita (recebe tudo do controller). A escrita é cercada por três hooks: `creating` (identidade), `saving` (unicidade de slug) e `saved`/`deleted` (propagação best-effort). Nenhum hook abre transação; cada efeito externo é independente e silenciado em caso de erro.

```mermaid
flowchart LR
  A[save] --> B[creating: id + slug]
  B --> C[saving: dedup slug]
  C --> D[(v2_eventos)]
  D --> E[saved]
  E --> F[syncToSearch]
  E --> G[FeedSender agenda]
  D --> H[deleted]
  H --> I[PublicIndexerService::remove]
  H --> J[FeedSender::remove]
```

### 2.2 Mecânica passo a passo

1. `Evento::boot()` registra os quatro hooks (`Models/Evento.php:67-159`).
2. `creating`: se `id` vazio, gera `v2_uuid()`; se `slug` vazio e `title` presente, aplica `str_slug`.
3. `saving`: parte de `slug` atual ou do título; para cada colisão com outro `id`, tenta `slug-1`, `slug-2`...; grava o resultado.
4. `INSERT`/`UPDATE` na tabela.
5. `saved`: chama `syncToSearch()` e, se `is_active`, monta o payload RSS; se inativo, remove do feed.
6. `deleted` (soft): remove do índice público e do feed; nenhum outro efeito.
7. `syncToSearch`: com `is_active`, cria `PublicSearchDTO(title, slug, 'Eventos', 'evento', id, strip_tags(description), ['agenda','eventos','sucesu'], description)` e chama `PublicIndexerService::index` (UPSERT por `entity_type`+`entity_id`); sem `is_active`, remove.
8. Feed: `FeedSender::forModule('agenda')->pushFromArray([...])` → `FeedItemDTO::fromArray` → `FeedRssService::addItem` (UPSERT, `enabled=true`).

### 2.3 Decisões de design e por quê

- **Identidade em `creating` e slug em `saving`** `[C]`: separa "primeira vez" de "toda gravação", permitindo que o controller já forneça o `slug` e ainda assim haja dedup.
- **Slug congelado no update** `[C]`: `$baseSlug = $model->slug ?: str_slug(title)`; como o `slug` já existe, o título novo é ignorado (`Models/Evento.php:83-99`). Preserva URLs e SEO.
- **Dedup por `exists()` antes do `INSERT`** `[C]`: simples e legível, mas sujeito a corrida (sem `lockForUpdate`, sem captura de `23505`).
- **Hooks best-effort** `[C]`: `try/catch` + `Log::error` em todos os efeitos externos; o save nunca falha por busca/feed.
- **`published_at` fora dos hooks** `[C]`: quem decide publicar é o controller; o model não normaliza data nem timezone.

### 2.4 Trechos de código pivô

```php
// V2/Modules/Publicacoes/Agenda/Models/Evento.php:83-102 — deduplicação de slug em toda gravação
static::saving(function ($model) {
    $baseSlug = $model->slug ?: str_slug($model->title);
    $slug = $baseSlug;
    $counter = 1;

    if (!empty($model->id)) {
        while (static::where('slug', $slug)->where('id', '!=', $model->id)->exists()) {
            $slug = $baseSlug . '-' . $counter;
            $counter++;
        }
    } else {
        while (static::where('slug', $slug)->exists()) {
            $slug = $baseSlug . '-' . $counter;
            $counter++;
        }
    }
    $model->slug = $slug;

    \Log::debug('[Evento] Hook saving: slug final=' . $model->slug . ', id=' . $model->id);
});
```

O `else` é praticamente inalcançável no fluxo do CMS (o controller sempre define `id`); ele existe para `new Evento()` sem id. A consulta ignora registros soft-deletados (escopo global), então um slug de evento arquivado pode ser reutilizado `[C]`.

```php
// V2/Modules/Publicacoes/Agenda/Models/Evento.php:260-273 — os dois eixos de publicação e o recorte temporal
public function scopePublished($query)
{
    return $query->whereNotNull('published_at')
        ->where('published_at', '<=', now());
}

/**
 * @param \Illuminate\Database\Eloquent\Builder $query
 * @return \Illuminate\Database\Eloquent\Builder
 */
public function scopeUpcoming($query)
{
    return $query->where('event_date_start', '>=', now()->startOfDay());
}
```

`published` não olha `is_active`; `upcoming` compara com o início do dia no fuso da aplicação (`America/Fortaleza`), não com a hora corrente `[C]`.

```php
// V2/Modules/Publicacoes/Agenda/Models/Evento.php:221-239 — indexação limitada a is_active
public function syncToSearch()
{
    try {
        if ($this->is_active) {
            $dto = new PublicSearchDTO(
                $this->title,
                $this->slug,
                'Eventos',
                'evento',
                $this->id,
                $this->description ? strip_tags($this->description) : '',
                ['agenda', 'eventos', 'sucesu'],
                $this->description ?? ''
            );

            app(PublicIndexerService::class)->index($dto);
        } else {
            app(PublicIndexerService::class)->remove('evento', $this->id);
        }
    } catch (\Exception $e) {
        \Log::error('[Evento] Falha no syncToSearch: ' . $e->getMessage());
    }
}
```

`search_deep_content` recebe a descrição **crua** (pode conter HTML/AST), enquanto o resumo é `strip_tags`. `published_at` não participa: rascunho ativo entra no índice `[C]`.

```php
// V2/Modules/Publicacoes/Agenda/Models/Evento.php:301-315 — semântica de evento contínuo
public function scopeForDate($query, $date)
{
    $carbon = $date instanceof \Carbon\Carbon ? $date : \Carbon\Carbon::parse($date);

    return $query->where(function ($q) use ($carbon) {
        $q->whereDate('event_date_start', $carbon)
          ->orWhere(function ($q2) use ($carbon) {
              $q2->where('event_date_start', '<=', $carbon)
                 ->where(function ($q3) use ($carbon) {
                     $q3->where('event_date_end', '>=', $carbon)
                        ->orWhereNull('event_date_end');
                 });
          });
    });
}
```

O uso de `whereDate` no primeiro ramo ignora a hora; os ramos `forWeek`/`forMonth` usam `whereBetween` com timestamps de limite (`:345-386`) `[C]`.

```php
// V2/Modules/Publicacoes/Agenda/Migrations/2024_01_01_000006_create_v2_eventos_table.php:22-38 — schema efetivo
$table->uuid('id')->primary();
$table->string('title', 255);
$table->string('slug', 255)->unique();
$table->string('image', 255)->nullable()->comment('Caminho da imagem do evento');
$table->text('description')->nullable()->comment('Descrição do evento');
$table->datetime('event_date_start')->comment('Data/hora de início do evento');
$table->datetime('event_date_end')->nullable()->comment('Data/hora de término do evento');
$table->string('location', 255)->nullable()->comment('Local do evento');
$table->string('organizer', 255)->nullable()->comment('Organizador do evento');
$table->string('target_audience', 255)->nullable()->comment('Público-alvo');
$table->text('topics_list')->nullable()->comment('Lista de tópicos (JSON array)');
$table->jsonb('schedule')->nullable()->comment('Programação do evento (JSON)');
$table->boolean('is_active')->default(true);
$table->timestamp('published_at')->nullable();
$table->string('created_by', 36)->nullable()->comment('FK para users');
$table->timestamps();
$table->softDeletes();
```

Não existe `metadata`; `image` é legado e nunca escrito pelo fluxo novo; `event_date_start` é `NOT NULL` `[C]`.

### 2.5 Algoritmos, consultas e custos

- **Slug**: O(k) consultas `exists()` onde k é o número de colisões; em importação com títulos repetidos, k cresce e o custo também `[C]`.
- **`forDate`/`forWeek`/`forMonth`**: OR de dois ramos; o planner pode não usar `idx_v2_eventos_date` por causa do `OR` `[I]`.
- **`ilike`**: sem índice trigram; varredura do recorte filtrado `[C]`.
- **`with('image')`**: 1 query extra para todos os cards (não N+1) `[C]`.
- **Hook `saved`**: 1 UPSERT em busca + 1 UPSERT/soft delete em feed + 1 registro de auditoria por item do feed `[C]`.
- **Soft delete**: `deleted_at` indexado apenas implicitamente? Não há índice em `deleted_at`; consultas padrão sempre filtram `deleted_at IS NULL` `[I]` (`Migrations/...:37`).
- **Volume**: sem paginação interna; consumidores aplicam `take(4/12/15/20)`.

## 3. Particularidades

### 3.1 O que só existe aqui

- Um único model com **dois eixos de publicação** e **três granularidades de calendário**.
- `schedule` JSONB e `topics_list` TEXT/JSON sem tabelas filhas.
- Hooks que falam com dois serviços distintos e tratam `is_active` como "publicado no índice" (não `published_at`).

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-AGENDA-07 | Slug é imutável após criação; colisão recebe `-N` | Título ≠ URL após edição | `Models/Evento.php:83-99` |
| RN-AGENDA-08 | `is_active` é booleano do banco, mas o controller só aceita a string `'1'` | `'true'`/`true` viram `false` | `EventosCmsController.php:125,234` |
| RN-AGENDA-10 | Busca/RSS usam `is_active`; listagem usa `published`+`active` | Rascunho indexado e no feed | `Models/Evento.php:224-239,260-264` |
| RN-AGENDA-11 | `destroy` é soft delete sem restauração | Registro permanece | `EventosCmsController.php:272` |
| RN-AGENDA-12 | `topics_list` é JSON de array; `schedule` é JSONB de array | Formato estável para views | `EventosCmsController.php:115-123` |
| RN-AGENDA-14 | RSS remove por soft delete e reinsere por `updateOrCreate` | Reativar pode violar unique e perder o item | `FeedRssService.php:42-59` |
| RN-AGENDA-17 | `getImageUrl` ignora a coluna `image` e resolve a relação | Consumidores que usam `$evento->image` ficam sem imagem | `Models/Evento.php:174-188` |
| RN-AGENDA-18 | `published_at` não é normalizado pelo model | Data inválida persiste se vier de código | `Models/Evento.php` (sem mutator) |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Slug reutilizado após soft delete**: o `exists()` não vê registros deletados; criar "X" depois de arquivar "X" gera `slug='x'` de novo, e a URL antiga passa a apontar para o novo evento `[I]`.
- **Título vazio**: `str_slug('')` produz `''`; o segundo evento sem título viola o unique `[I]`.
- **`schedule` com chaves esparsas**: o controller aplica `array_values`, então índices enviados como `5` viram `0` `[C]`.
- **`topics_list` como array no model**: o cast não existe; se alguém atribuir array, o PDO tenta converter e grava `"Array"` (warning) `[I]`.
- **`event_date_end` anterior ao início via código** (não via CMS): aceito no banco; filtros de semana/mês podem incluir o evento em intervalos onde ele não deveria estar `[C]`.
- **`is_active` default do banco**: `true`; um `new Evento` + `save` sem tocar no campo nasce ativo `[C]`.
- **Imagem apagada**: relação retorna `null` (SoftDeletes na galeria); `getImageUrl()` devolve `null` sem erro `[C]`.
- **`getImageVariants`**: assume `variants` como string JSON; se o serviço mudar para array, `json_decode(array)` emite warning e retorna `null` `[I]`.

### 3.4 Armadilhas de reimplementação

- Não confundir `published` com "visível": o detalhe ignora `is_active` (ver [`spec.md`](spec.md) §3.2).
- Não remover o `orWhereNull('event_date_end')`: muda radicalmente os filtros de calendário.
- Não trocar `str_slug` por `Str::slug` com locale: slugs antigos são ASCII lower-case com hífens; `Slug` com transliteração diferente quebra URLs.
- Não usar `updateOrCreate` no feed sem restaurar (`withTrashed`) ou limpar a linha antiga: RN-AGENDA-14.
- Não assumir transação entre `v2_eventos` e os índices: são efeitos independentes e best-effort.
- Não indexar rascunhos "porque `is_active` está true": é o comportamento legado (RN-AGENDA-10), mas deve ser decisão consciente na reimplementação.

### 3.5 Divergências e dívidas

- `metadata` em `$fillable`/`$casts` sem coluna; `created_by` sem escritor (LG-AGENDA-01/08).
- `protected $dates` (legado Laravel 5.x) convive com `$casts` de data — redundante.
- `image` legado sem uso e sem migração de dados para `image_id` (LG-AGENDA-06).
- `image_id` `varchar(36)` contra PK inteira da galeria (LG-AGENDA-02).
- Busca indexa HTML/AST cru em `search_deep_content`, degradando o `tsvector` `[I]`.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `Evento::scopePublished` | scope | `published()` | — | portal, homepage, dashboard |
| `Evento::scopeActive` | scope | `active()` | — | portal, homepage |
| `Evento::scopeUpcoming` | scope | `upcoming()` | — | portal, homepage, dashboard |
| `Evento::scopeUpcomingOrdered` | scope | `upcomingOrdered()` | — | portal, homepage, widget |
| `Evento::scopeRecent` | scope | `recent()` | — | nenhum hoje |
| `Evento::scopeForDate` | scope | `forDate($date)` | data parseável | portal |
| `Evento::scopeForWeek` | scope | `forWeek($date)` | data parseável | portal |
| `Evento::scopeForMonth` | scope | `forMonth($date)` | data parseável | portal |
| `Evento::weekBounds` | static | `[$startOfWeek, $endOfWeek]` | — | portal |
| `Evento::getImageUrl` | método | `?string` | `image_id` + relação | views, RSS |
| `Evento::getImageVariants` | método | `array` | `variants` JSON | `picture` |
| `Evento::getPublicUrl` | método | `?string` | rota registrada | RSS |
| `Evento::syncToSearch` | método | `void` | `is_active` | hook, reindexação manual |

### 4.2 Contrato de UI/JS

Não se aplica ao model. Os campos consumidos pela UI são `title`, `event_date_start`, `event_date_end`, `location`, `organizer`, `target_audience`, `topics_list`, `schedule`, `image_id`; formatos e componentes estão em [`cms.md`](cms.md) e [`portal.md`](portal.md).

### 4.3 Ordem e ciclo de vida

1. `v2_gen_uuid()` (core) antes das migrations do módulo — a tabela não usa default no banco.
2. `v2_gallery_media` antes de qualquer `image_id` válido.
3. `v2_public_search_index` e `v2_feed_rss_items` podem não existir: os hooks falham e logam.
4. Hooks só rodam com Eloquent; `DB::table('v2_eventos')->insert()` não gera slug, índice nem feed.
5. `Evento::withoutEvents()` em importações evita efeitos, mas exige reindexação e slug manual.

### 4.4 Receitas de uso

**Criar respeitando os hooks:**

```php
$evento = new \V2\Modules\Publicacoes\Agenda\Models\Evento();
$evento->title = 'Workshop de IA';
$evento->event_date_start = \Carbon\Carbon::parse('2026-12-01 09:00');
$evento->event_date_end = \Carbon\Carbon::parse('2026-12-01 18:00');
$evento->is_active = true;
$evento->published_at = now();
$evento->save();
// $evento->slug === 'workshop-de-ia'; índice e feed atualizados
```

**Consultar o recorte público canônico:**

```php
$eventos = Evento::published()->active()->upcoming()->upcomingOrdered()->with('image')->paginate(12);
```

**Obter a mídia corretamente:**

```php
$url = $evento->getImageUrl();               // ?string
$variants = $evento->getImageVariants();     // ['480' => '...', '1200' => '...']
```

**Reindexar em lote após importação:**

```php
Evento::withTrashed()->chunk(200, function ($lote) {
    foreach ($lote as $e) { $e->syncToSearch(); }
});
```

### 4.5 Anti-uso

- **`$evento->image`**: retorna a coluna `image` (nula), não a relação; use `getRelationValue('image')`/`getImageUrl()`.
- **`Evento::create([...])` sem `event_date_start`**: erro de NOT NULL no banco; o model não valida.
- **`DB::table('v2_eventos')->insert()`**: não gera UUID nem slug e não sincroniza busca/feed.

### 4.6 Custos e limites

- Sem limite de tamanho para `description`/`topics_list`/`schedule` no servidor.
- `schedule` JSONB aceita qualquer estrutura; o contrato `{time, activity, speaker}` é apenas convenção das views.
- Cada save dispara até 4 operações externas; em lote, prefira desabilitar hooks.
- `slug` único é a única constraint de negócio no banco.

## 5. Rotas e contratos HTTP

Este documento não define rotas; ver [`rotas.md`](rotas.md). O único contrato relevante aqui é que **toda** escrita passa por `Evento::save()` (CMS) ou pelo seeder, e que o `id` do path CMS é o UUID da PK.

## 6. Dados e modelo de domínio

### 6.1 Colunas de `v2_eventos`

| Coluna | Tipo | Null | Default | Cast/uso | Fonte |
|---|---|---|---|---|---|
| `id` | uuid | não | — | PK; gerado em PHP | `Migrations/2024_01_01_000006...:23` |
| `title` | varchar(255) | não | — | exibição/SEO | `:24` |
| `slug` | varchar(255) | não | — | unique; URL | `:25` |
| `image` | varchar(255) | sim | — | legado, não lido | `:26` |
| `description` | text | sim | — | texto ou AST | `:27` |
| `event_date_start` | datetime | não | — | cast datetime | `:28` |
| `event_date_end` | datetime | sim | — | cast datetime | `:29` |
| `location` | varchar(255) | sim | — | exibição/busca | `:30` |
| `organizer` | varchar(255) | sim | — | exibição/busca | `:31` |
| `target_audience` | varchar(255) | sim | — | exibição/categoria RSS | `:32` |
| `topics_list` | text | sim | — | JSON array manual | `:33` |
| `schedule` | jsonb | sim | — | cast array | `:34` |
| `is_active` | boolean | não | `true` | cast boolean | `:35` |
| `published_at` | timestamp | sim | — | cast datetime | `:36` |
| `created_by` | varchar(36) | sim | — | nunca escrito | `:37` |
| `created_at`/`updated_at` | timestamp | sim | — | timestamps | `:38` |
| `deleted_at` | timestamp | sim | — | soft delete | `:39` |
| `image_id` | varchar(36) | sim | — | relação galeria | `2026_07_20_000002...:24-29` |

Índices: `idx_v2_eventos_active`, `idx_v2_eventos_date`, `idx_v2_eventos_published` (`:41-43`) e `idx_v2_eventos_image_id` (`2026_07_20_000002...:30`). Unique: `slug`.

### 6.2 Classes e casts

`$table='v2_eventos'`; `$incrementing=false`; `$keyType='string'`; `$fillable` = `id,title,slug,image_id,description,event_date_start,event_date_end,location,organizer,target_audience,topics_list,schedule,is_active,published_at,metadata`; `$casts` = `is_active:boolean`, três `datetime`, `schedule:array`, `metadata:array`; `$dates` inclui `created_at,updated_at,deleted_at,event_date_start,event_date_end,published_at`; `SoftDeletes` (`Models/Evento.php:19-64`).

### 6.3 Exemplos de payload persistido

```json
{
  "topics_list": "[\"LGPD\",\"Governança de dados\",\"Riscos e conformidade\"]",
  "schedule": [
    {"time": "09:00", "activity": "Mapeamento de dados pessoais", "speaker": "Consultor convidado"},
    {"time": "", "activity": "", "speaker": ""}
  ]
}
```

A linha vazia é o que o formulário envia por padrão e o controller persiste `[C]` (`Views/cms/_form.blade.php:133-150`; `EventosCmsController.php:120-123`).

### 6.4 Diagrama

```mermaid
erDiagram
  V2_EVENTOS {
    uuid id PK
    varchar slug UK
    datetime event_date_start
    datetime event_date_end
    boolean is_active
    timestamp published_at
    varchar image_id
    timestamp deleted_at
  }
  V2_GALLERY_MEDIA {
    int id PK
    varchar file_path
    jsonb variants
  }
  V2_EVENTOS }o--o| V2_GALLERY_MEDIA : "image_id (sem FK)"
```

### 6.5 Invariantes de dados

- `event_date_start` sempre presente (NOT NULL + validação).
- `slug` sempre único e não vazio na prática (título obrigatório no CMS).
- `is_active` default `true` no banco, mas o CMS envia `0` quando desmarcado.
- `published_at` nulo = rascunho; data futura = agendado.
- `topics_list`/`schedule` são JSON textuais, não relações.

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> Rascunho
  Rascunho --> Publicado: published_at preenchido
  Publicado --> Rascunho: published_at limpo
  Rascunho --> Arquivado: delete()
  Publicado --> Arquivado: delete()
  Arquivado --> [*]
```

```mermaid
sequenceDiagram
  participant C as Controller
  participant E as Evento
  participant D as v2_eventos
  participant S as Índice
  participant F as Feed
  C->>E: save()
  E->>E: creating/saving
  E->>D: INSERT/UPDATE
  D-->>E: ok
  E->>S: syncToSearch (is_active)
  E->>F: push/remove
  E-->>C: retorno
```

## 8. UI CMS

Não se aplica a este documento (dados/modelo). Campos e mensagens: [`cms.md`](cms.md).

## 9. UI Portal

Não se aplica a este documento. Escopos usados pelo portal: `published`, `active`, `upcoming`/`forDate`/`forWeek`/`forMonth`, `upcomingOrdered` (ver [`portal.md`](portal.md)).

## 10. Configuração, permissões e flags

- `cache.prefix` não afeta o model.
- Flag `Agenda` só bloqueia rotas CMS (`module.active:Agenda`); o model continua acessível ao portal.
- RBAC `Agenda:read/write` é de rota, não de model.
- Fuso `America/Fortaleza` define `now()` em `published`/`upcoming` (`config/app.php:71`).

## 11. Observabilidade, erros e limites

- Logs `debug` em todos os hooks (`creating`, `saving`, `saved`, `deleted`) com id/slug/título.
- Erros de busca/feed são `Log::error` e não interrompem o save.
- Falha de slug único vira `QueryException` e `500`.
- Sem métricas, sem eventos de domínio, sem fila.
- Não há retenção/expurgo de soft-deleted.

## 12. Seeds e dados iniciais

- Nenhum dado semeado pela unidade.
- `DemoContentSeeder` (unidade `DATA-SEEDS-CONTENT`) cria 5 eventos com `published_days_ago` e offsets (`V2/Seeds/Content/DemoContentSeeder.php:611-668`); idempotência por `Evento::count() > 0`.
- `PopulateMissingImages` escreve `image_id` em eventos nulos (`V2/Services/Gallery/Console/Commands/PopulateMissingImages.php:91-102`).

## 13. Lacunas

- **LG-AGENDA-01** — coluna `metadata` ausente; SEO do evento inoperante.
- **LG-AGENDA-02** — tipo de `image_id` divergente da PK da galeria.
- **LG-AGENDA-06** — sem migração de `image` legado para `image_id`; eventos antigos podem ficar sem capa.
- **LG-AGENDA-08** — autoria só em `activity_log`.
- **LG-AGENDA-09** — não foi possível determinar se o índice de busca deveria filtrar `published_at`; o código não filtra. Onde procurar: issues/commits do serviço Search.

## 14. Checklist de paridade

- [ ] `PC-AGENDA-01` — `save()` sem id gera UUID v4 e slug a partir do título.
- [ ] `PC-AGENDA-03` — colisão de slug gera `-1`, `-2`; update mantém o slug.
- [ ] `PC-AGENDA-05` — `is_active=false` remove índice e feed; `true` reinsere (ressalva RN-AGENDA-14).
- [ ] `PC-AGENDA-06` — `published` exige `published_at <= now()`.
- [ ] `PC-AGENDA-07` — `forDate` inclui contínuos sem término.
- [ ] `PC-AGENDA-08` — `weekBounds` retorna domingo 00:00 e sábado 23:59:59.
- [ ] `PC-AGENDA-19` — `getImageUrl` resolve a relação, nunca a coluna `image`.
- [ ] `PC-AGENDA-21` — soft delete remove do índice e do feed e mantém a linha.

## 15. Evidências consultadas

- `V2/Modules/Publicacoes/Agenda/Models/Evento.php:1-387`.
- `V2/Modules/Publicacoes/Agenda/Migrations/2024_01_01_000006_create_v2_eventos_table.php:20-51`.
- `V2/Modules/Publicacoes/Agenda/Migrations/2026_07_20_000002_add_image_id_to_v2_eventos_table.php:21-37`.
- `V2/Modules/Publicacoes/Agenda/Controllers/EventosCmsController.php:77-159,187-305`.
- `V2/Services/Search/Services/PublicIndexerService.php:21-62`.
- `V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:40-52`.
- `V2/Services/FeedRSS/Services/FeedRssService.php:39-70`.
- `V2/Services/FeedRSS/DTOs/FeedItemDTO.php:180-195`.
- `V2/Services/Gallery/Migrations/2026_06_28_000000_create_v2_gallery_media_table.php:27-42`.
- `V2/Core/Helpers/helpers.php:61-69`.
- `V2/Seeds/Content/DemoContentSeeder.php:604-668`.
