# A Cidade — Modelagem de Dados

> Versão analisada: `v6`. Tipos e nullability conferidos com `\d` no PostgreSQL
> da base de desenvolvimento (`prefeituras_v5`).

---

## 1. Visão geral

O módulo A Cidade **não possui tabela própria**. Todo o dado vive em quatro
tablas genéricas:

| Tabela | Papel no módulo |
|---|---|
| `category_contents` | Âncora: a linha `id = 4`, `slug = 'a-cidade'`. |
| `contents` | Os textos institucionais (História, Mapa, Hino Oficial, Fotos da Cidade). |
| `archives` | Imagem de destaque e galeria de fotos de cada conteúdo. |
| `links` | O link único (`linksUnique`) usado como **vídeo** do conteúdo. |

E em um objeto de banco que é uma **view SQL**, não uma tabela:

| View SQL | Papel no módulo |
|---|---|
| `a_cidade` | Alimenta a listagem (DataTable) do CMS. |

## 2. `category_contents` — categoria âncora

Migration de criação: `v6/database/migrations/2018_04_01_180916_create_category_contents_table.php`.

| Coluna | Tipo (PostgreSQL) | Null | Default | Observação |
|---|---|---|---|---|
| `id` | `integer` | não | `nextval('category_contents_id_seq')` | PK |
| `name` | `character varying(191)` | não | — | `A cidade` |
| `slug` | `character varying(191)` | não | — | **`a-cidade`** — é a chave lógica do módulo |
| `status_create` | `boolean` | sim | — | Permite criar conteúdo nesta categoria |
| `quantidade_itens` | `integer` | sim | — | Não usado por A Cidade |
| `order` | `integer` | sim | — | Não usado por A Cidade |
| `layout` | `integer` | sim | — | Não usado por A Cidade |
| `created_at` / `updated_at` | `timestamp(0)` | sim | — | — |
| `deleted_at` | `timestamp(0)` | sim | — | Soft delete |
| `category_contents_id` | `integer` | sim | — | Auto-FK (hierarquia de categorias) |
| `status` | `boolean` | sim | `true` | Adicionado por `2021_02_18_123218_add_status_to_category_contents.php` |
| `display_type` | `character varying(191)` | sim | — | `2026_06_01_100000_add_display_type_and_custom_icon_to_category_contents.php` |
| `custom_icon` | `character varying(191)` | sim | — | idem |
| `description` | `text` | sim | — | `2026_06_04_100000_add_description_to_category_contents_table.php` |

Linha real (consulta de leitura):

```
 id |    name     |   slug   | status | category_contents_id
----+--------------+----------+--------+----------------------
  4 | A cidade    | a-cidade | t      | (null)
```

> **Ponto de atenção:** o `id = 6` **não** é mais `a-prefeitura`. Na base atual
> é `A Instituição` / `a-instituicao`. A renomeação é feita por *seed*
> (`v6/database/seeds/CategoryContentTableSeeder.php:41-53`), não por migration.
> Detalhes em `08-GAPS-E-DIVIDAS.md`.

## 3. `contents` — os textos institucionais

Migration de criação: `v6/database/migrations/2018_04_02_180433_create_contents_table.php`.
Colunas adicionadas depois: `2022_04_19_084011_add_secretaria_to_contents_table.php`,
`2022_05_06_103629_add_column_to_contents_table.php`,
`2023_05_19_114307_add_orgao_to_content.php`,
`2023_08_17_094529_add_position_id_to_contents_table.php`,
`2025_04_23_112404_add_acronym_to_contents.php`,
`2026_03_09_145651_add_layout_in_contents.php`,
`2026_06_12_100000_add_councilmen_id_to_contents_table.php`,
`2026_06_25_000001_add_linha_fina_to_contents_table.php`,
`2026_06_26_100000_add_referencia_to_contents_table.php`.

| Coluna | Tipo | Null | Default | Usada por A Cidade |
|---|---|---|---|---|
| `id` | `integer` | não | `nextval('contents_id_seq')` | PK |
| `title` | `character varying(191)` | sim | — | ✅ título exibido |
| `text` | `text` | sim | — | ✅ corpo HTML (Summernote) |
| `clicks` | `integer` | sim | — | ✅ incrementado a cada visita |
| `highlights` | `boolean` | sim | — | Não (destaque é em `archives`) |
| `galery` | `boolean` | sim | — | ✅ liga a galeria de fotos |
| `status` | `boolean` | sim | — | ⚠ **não filtrado** na rota nem na view |
| `source` | `character varying(191)` | sim | — | Não |
| `author` | `character varying(191)` | sim | — | Não |
| `icon` | `character varying(191)` | sim | — | Não |
| `slug` | `character varying(191)` | sim | — | ✅ **chave da rota pública** |
| `publication_date` | `timestamp(0)` | sim | — | Não (não há data na tela) |
| `order` | `integer` | sim | — | Não |
| `category_contents_id` | `integer` | **não** | — | ✅ FK → `category_contents.id` (4) |
| `created_at` / `updated_at` | `timestamp(0)` | sim | — | ✅ ordena a view SQL |
| `deleted_at` | `timestamp(0)` | sim | — | ✅ filtrado na view SQL |
| `secretaria` | `character varying(191)` | sim | — | Não |
| `carousel` | `boolean` | sim | — | Não |
| `orgaos_id` | `integer` | sim | — | Não |
| `position_id` | `bigint` | sim | — | Não |
| `councilmen_id` | `integer` | sim | — | Não |
| `acronym` | `character varying(191)` | sim | — | Não |
| `layout` | `integer` | sim | — | Não |
| `linha_fina` | `character varying(300)` | sim | — | Não |
| `referencia` | `character varying(50)` | sim | — | Não |

FKs: `contents_category_contents_id_foreign` → `category_contents(id)`;
`contents_councilmen_id_foreign` → `councilmen(id) ON DELETE SET NULL`;
`contents_orgaos_id_foreign` → `contents(id)`.

**Não existe índice único em `contents.slug`.** A colisão é resolvida em runtime,
no `store()` (sufixo `-<max(id)+1>`).

### 3.1 Conteúdos cadastrados (base de desenvolvimento)

```
 id |      title      |       slug       | status | clicks | category_contents_id
----+-----------------+------------------+--------+--------+----------------------
  2 | Mapa            | mapa             | (null) |      1 |                    4
  3 | Hino Oficial    | hino-oficial     | (null) |      1 |                    4
  4 | Fotos da Cidade | fotos-da-cidade  | (null) |      1 |                    4
  1 | História        | historia         | (null) |      1 |                    4
```

Os quatro vêm do seed `v6/database/seeds/ContentsTableSeeder.php:16-47`, com
`text = 'Em breve...'`. Note que `status` é **NULL** em todos — e, como nem a
rota nem a view filtram `status`, eles são servidos normalmente.

## 4. `archives` — destaque e galeria

Migration de criação: `v6/database/migrations/2018_04_02_181807_create_archives_table.php`.
Colunas relevantes para o módulo: `highlight` (destaque), `path`, `name`,
`extension`, `order`, `contents_id`.

| Coluna | Tipo | Null | Observação |
|---|---|---|---|
| `id` | `integer` | não | PK |
| `title` | `character varying(300)` | sim | — |
| `description` | `text` | sim | Usado como `alt`/`title` na galeria |
| `type` | `character varying(191)` | sim | MIME type |
| `hash` | `character varying(300)` | sim | — |
| `path` | `character varying(300)` | sim | ✅ caminho relativo (`storage/content/a-cidade/...`) |
| `name` | `character varying(300)` | sim | ✅ nome do arquivo |
| `extension` | `character varying(191)` | sim | `jpg`, `png`, ... |
| `status_create` | `boolean` | sim | — |
| `highlight` | `boolean` | sim | ✅ `1` = imagem principal; `NULL` = galeria |
| `contents_id` | `integer` | sim | ✅ FK → `contents(id)` |
| `order` | `integer` | sim | Ordenação da galeria |

Relações Eloquent usadas pela tela (`v6/app/Http/Model/Prefecture/Content.php`):

```php
// :183-186
public function archiveHighlight()
{
    return $this->hasOne(Archive::class, 'contents_id', 'id')->byHighLight(1);
}

// :192-195
public function archiveGallery()
{
    return $this->hasMany(Archive::class, 'contents_id', 'id')->byHighLight(null)->orderBy('created_at', 'desc');
}
```

> `archiveGallery()` **não** filtra `extension <> 'pdf'`, ao contrário do que a
> `ContentController@returnData` faz na tela de edição
> (`v6/app/Http/Controllers/Prefecture/ContentController.php:290`). Na página
> pública, um PDF na galeria é renderizado como imagem quebrada.

## 5. `links` — o vídeo

Migration de criação: `v6/database/migrations/2018_04_02_181150_create_links_table.php`.
Coluna `link` alterada em `v6/database/migrations/2019_09_10_161305_alter_link_to_links_table.php`
(para `character varying(3000)`).

| Coluna | Tipo | Null | Observação |
|---|---|---|---|
| `id` | `integer` | não | PK |
| `title` | `character varying(191)` | sim | Copiado do título do conteúdo |
| `link` | `character varying(3000)` | sim | ✅ URL do vídeo (YouTube, Vimeo, Facebook, Instagram) |
| `alt` | `character varying(191)` | sim | — |
| `name` | `character varying(191)` | sim | — |
| `isBlank` | `boolean` | sim | Abrir em nova aba |
| `status_create` | `boolean` | sim | — |
| `contents_id` | `integer` | sim | ✅ FK → `contents(id)` |
| `static_pages_id` | `integer` | sim | FK → `static_pages(id)` |

Relação Eloquent (`v6/app/Http/Model/Prefecture/Content.php:114-117`):

```php
public function linksUnique()
{
    return $this->hasOne(Link::class, 'contents_id', 'id');
}
```

O `hasOne` **não** garante unicidade: se o operador salvar o formulário duas
vezes, o `update()` reaproveita o link existente (`ContentController.php:1063`),
mas um `store()` duplicado criaria dois links e o `hasOne` ficaria ambíguo.

## 6. A view SQL `a_cidade`

### 6.1 Definição

```sql
-- v6/database/migrations/2024_11_29_094836_update_view_a_cidade.php:17-27
CREATE OR REPLACE VIEW a_cidade AS
SELECT row_number() OVER (ORDER BY c.created_at DESC) AS value,
    c.title AS titulo,
    c.id as id,
    c.slug as slug
FROM contents c
    JOIN category_contents cc ON cc.id = c.category_contents_id
WHERE c.deleted_at IS NULL AND cc.slug = 'a-cidade'
ORDER BY c.created_at DESC;
```

### 6.2 Colunas expostas ( conferidas com `\d a_cidade`)

| Coluna | Tipo PostgreSQL | Origem |
|---|---|---|
| `value` | `bigint` | `row_number()` — número da linha (recalculado a cada consulta) |
| `titulo` | `character varying(191)` | `contents.title` |
| `id` | `integer` | `contents.id` |
| `slug` | `character varying(191)` | `contents.slug` |

### 6.3 O que a view abre e o que ela esconde

| Situação | Aparece na view? |
|---|---|
| Conteúdo da categoria `a-cidade` | ✅ |
| Conteúdo de outra categoria | ❌ (filtro `cc.slug = 'a-cidade'`) |
| Conteúdo com `deleted_at` preenchido | ❌ (soft delete respeitado) |
| Conteúdo com `status = false` (rascunho) | ✅ **aparece** — não há filtro de status |
| Conteúdo com `status = NULL` | ✅ **aparece** — `NULL` não é `false` |

### 6.4 Consumo

```php
// v6/app/Http/Controllers/Prefecture/DatatableController.php:246-250
public function get_a_cidade()
{
    $acidade = DB::table('a_cidade')->get();
    return DataTables::of($this->struct_views($acidade, 'a-cidade'))->make(true);
}
```

`DB::table(...)` **ignora o SoftDeletes do Eloquent** — por isso a view precisa
do próprio `deleted_at IS NULL`.

## 7. Migrations do módulo

| Data / arquivo | Efeito |
|---|---|
| `v6/database/migrations/2023_04_10_144637_create_view_to_a_cidade.php` | Cria a view `a_cidade` **sem** a coluna `slug`. |
| `v6/database/migrations/2024_11_29_094836_update_view_a_cidade.php` | `DROP VIEW` + recria **com** `c.slug AS slug`. O `down()` volta à versão sem `slug`. |

A migration inicial (idêntica em `v5/database/migrations/2023_04_10_144637_create_view_to_a_cidade.php`)
já filtrava `cc.slug = 'a-cidade'` — o slug da categoria **não** mudou entre v5 e
v6; o que mudou foi o prefixo **público** de rotas e o nome do diretório de
views (ver `08-GAPS-E-DIVIDAS.md`).

## 8. Seeds

| Arquivo | O que faz |
|---|---|
| `v6/database/seeds/CategoryContentTableSeeder.php:30-34` | `updateOrCreate(['slug' => 'a-cidade'], ['name' => 'A cidade', 'status_create' => 1])` — garante a categoria âncora. |
| `v6/database/seeds/ContentsTableSeeder.php:16-47` | Cria os 4 conteúdos (`historia`, `mapa`, `hino-oficial`, `fotos-da-cidade`) com texto `Em breve...`. |
| `v6/database/seeds/MenuOrderSeeder.php:64-69` | `menu_orders` com `slug = 'a-cidade'`, `modulo_id = 13` — usado pelo menu do CMS e por `menuClicks()`. |
| `v6/database/seeds/ResourcesTableSeeder.php:80-88` | `resources` com `slug = 'a-cidade'`, `modulo_id = 13` — recurso de permissão. |
| `v6/database/seeds/LinksTableSeeder.php:18-26` | Cria o `link` de menu "A Cidade" (o `menu_item` do cabeçalho do portal). |
| `v6/database/seeds/MenuItemsTableSeeder.php:32-39` | `menu_items` "A Cidade" ligado ao `link` acima, para os menus 1 e 2. |
| `v6/database/migrations/2024_11_01_150206_create_modulos_table.php:101-106` | Insere `modulos` `id = 13`, `name = 'A Cidade'`, `slug = 'a-cidade'`, **`status = 0`** (módulo desativado). |

## 9. Papéis e permissões

As permissões são gravadas como JSON na coluna `roles.permissions`. Para o papel
`administrador` (`roles.slug = 'administrador'`):

```
"a-cidade.index":true
"a-cidade.create":true
"a-cidade.edit":true
"a-cidade.delete":true
"a-cidade.inactive":true
"a-cidade.inactivate":true
"a-cidade.restore":true
"a-cidade.show":true
```

O papel `super-admin` não depende disso: `has_access()` faz curto-circuito em
`Sentinel::inRole('super-admin')` (`v6/app/Library/Helper/appHelpers.php:34-36`).

A tabela `resources` (id 12) liga o slug `a-cidade` ao `modulos.id = 13`:

```
 id |   slug   |    name    | modulo_id
----+----------+------------+-----------
 12 | a-cidade | A Cidade   |        13
```

## 10. Diagrama relacional

```
category_contents (id=4, slug='a-cidade')
        ▲
        │ category_contents_id (FK, NOT NULL)
        │
   contents ──────────────► archives   (contents_id, highlight=1 → destaque)
        │                        ▲
        │                        └── (highlight IS NULL → galeria)
        │
        └────────────────────► links     (contents_id → vídeo, `linksUnique`)
```
