---
id: MOD-PUBLICACOES-TRABALHECONOSCO-DADOS
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-PUBLICACOES-TRABALHECONOSCO]
fontes:
  - V2/Modules/Publicacoes/TrabalheConosco/
depende_de: [CORE-DATA, CORE-BOOT, SVC-SEARCH, SVC-FEEDRSS, SVC-ACTIVITYLOGGER, SVC-MEDIA]
consumidores: [MOD-LAYOUTS-HOMEPAGE, SVC-DASHBOARD, SVC-SEARCH, SVC-FEEDRSS, MOD-SUPPORT-SUPPORTCONFIGS]
criticidade: media
dono: Trabalhe
---

# Publicações/TrabalheConosco — dados

> Anexo do `spec.md`. Duas tabelas de domínio (`v2_job_openings`, `v2_job_applications`),
> uma linha de feature flag (`v2_module_flags`) e três tabelas de efeito indireto
> (`v2_public_search_index`, `v2_feed_rss_items`, `activity_log`). PKs são UUID v4
> geradas em PHP; ambas as tabelas usam soft delete; a FK de candidaturas é
> `ON DELETE CASCADE`, mas a exclusão real do módulo é lógica.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Persistir vagas e candidaturas, manter o índice de busca/feed em sincronia por hooks e registrar a desativação do módulo na tabela de flags. |
| Entrada | Migrations do módulo; `JobOpeningController` (create/update/delete), `TrabalheConoscoController@apply`, `JobApplicationController` (status/delete), seeder demo e comando de capas. |
| Saída | Linhas em `v2_job_openings`/`v2_job_applications`; upsert/remoção em `v2_public_search_index` e `v2_feed_rss_items`; linhas em `activity_log`; arquivo de currículo no disco `local`. |
| Efeitos colaterais | Slug gerado/verificado a cada save; busca e feed atualizados a cada save; auditoria com IP/user-agent; flag inserida/atualizada sem auditoria. |
| Particularidade central | `v2_job_openings` acumula três gerações de schema (colunas legadas `description`/`requirements`/`benefits`, depois `description_detailed`/`metadata`, depois `cover_image_id`/`attachment_pdf_id`); nenhuma coluna é removida. |
| Como se integra | Models Eloquent em `V2\Modules\Publicacoes\TrabalheConosco\Models`; sem repositório/serviço de domínio; hooks estáticos no `boot()`. |
| Se removido, quebra | Dashboard perde duas estatísticas; busca/feed perdem a fonte `trabalhe-conosco`; seeder demo e comando de capas falham ao carregar classes. |
| Superfície pública | Colunas em §6.1/§6.2; constantes e scopes dos models; formato `metadata['seo']` lido pelo `SEOService`. |

## 1. Efeito

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

- Ver, no CMS, uma vaga com todos os campos persistidos — inclusive descrição rica,
  capa e PDF — após reload (nenhum campo é virtual).
- Ver, no portal, apenas vagas que satisfazem `is_active` + `closes_at` (`JobOpening.php:232-239`).
- Ver, no CMS, candidaturas com dados pessoais, carta, status, notas, revisor e data de
  revisão, mesmo depois de a vaga ser arquivada.
- Baixar o currículo pelo link `/storage/<resume_path>` (ver `LG-TRABALHECONOSCO-02`).
- Perder o acesso ao módulo inteiro quando `v2_module_flags.active=false` para
  `trabalheconosco`, sem que nenhum dado seja apagado.

### 1.2 O que muda no sistema

| Efeito | Tabela/arquivo | Onde é produzido | Fonte |
|---|---|---|---|
| Vaga criada com UUID e slug | `v2_job_openings` | `JobOpeningController@store` + hook `creating/saving` | `JobOpeningController.php:88-108`; `JobOpening.php:101-128` |
| Vaga atualizada (inclusive `is_active`, datas, mídias) | `v2_job_openings` | `JobOpeningController@update` | `JobOpeningController.php:168-185` |
| Vaga soft-deleted | `v2_job_openings.deleted_at` | `JobOpeningController@destroy` | `JobOpeningController.php:213` |
| Índice de busca criado/atualizado | `v2_public_search_index` | `syncToSearch` no hook `saved` | `JobOpening.php:130-135,201-213`; `PublicIndexerService.php:27-51` |
| Índice de busca removido | `v2_public_search_index` | hook `saved` (inativa) / `deleted` | `JobOpening.php:158-159,166-174,189-198` |
| Item de feed criado/atualizado | `v2_feed_rss_items` | hook `saved` | `JobOpening.php:137-157`; `FeedRssService.php:39-60` |
| Item de feed removido | `v2_feed_rss_items` | hook `saved` (inativa) / `deleted` | `JobOpening.php:158-160,176-180` |
| Candidatura criada (`pending`) | `v2_job_applications` | `TrabalheConoscoController@apply` | `TrabalheConoscoController.php:95-105` |
| Currículo gravado | `storage/app/resumes/<hash>` | `store('resumes','local')` | `TrabalheConoscoController.php:93` |
| Status, notas, revisor, data de revisão | `v2_job_applications` | `JobApplicationController@updateStatus` | `JobApplicationController.php:84-93` |
| Candidatura soft-deleted e arquivo removido | `v2_job_applications.deleted_at`; disco `local` | `JobApplicationController@destroy` | `JobApplicationController.php:118-126` |
| Flag do módulo desativada | `v2_module_flags` | migration `2026_09_03_000001` | `2026_09_03_000001...:23-55` |
| Auditoria de cada escrita CMS | `activity_log` | `ActivityLoggerService::log` | `ActivityLoggerService.php:20-38` |
| Capa atribuída a vagas sem mídia | `v2_job_openings.cover_image_id` | `PopulateMissingImages::populateVagas` | `PopulateMissingImages.php:173-186` |

### 1.3 Contrato observável (dado → efeito)

| Dado | Valor | Efeito observável |
|---|---|---|
| `v2_job_openings.is_active` | `true` + `closes_at` nulo/futuro | aparece no portal e no dashboard |
| `v2_job_openings.is_active` | `false` | some do portal, da busca (remoção) e do feed (remoção); continua no CMS |
| `v2_job_openings.closes_at` | passado | some do portal mesmo com `is_active=true` |
| `v2_job_openings.slug` | único | `GET /trabalhe-conosco/{slug}` resolve; duplicata é impossível por sufixo |
| `v2_job_openings.metadata['seo']` | array | `SEOService::bindModel` aplica `title`/`description`/`keywords`/`image_url`/`json_ld` (`SEOService.php:29-58`) |
| `v2_job_openings.cover_image_id` | UUID de `v2_gallery_media` | capa renderizada por `picture` no detalhe; sem FK, UUID inválido só não resolve |
| `v2_job_openings.attachment_pdf_id` | UUID de `v2_gallery_media` | botão “Baixar Edital / Ficha Técnica” |
| `v2_job_applications.status` | `pending` | card “Candidaturas Pendentes” conta (`JobApplication::pending`) |
| `v2_job_applications.status` | qualquer dos 5 | badge colorido e select do CMS |
| `v2_job_applications.resume_path` | caminho relativo | link de download no CMS |
| `v2_module_flags.active` | `false` (`module_key='trabalheconosco'`) | todas as rotas 404, item oculto no menu |
| `activity_log` | `created/updated/deleted/status_updated` | histórico consultável por subject (não há tela neste módulo) |

### 1.4 O que este anexo NÃO cobre

- Não especifica tabelas de outros módulos além das colunas que este módulo escreve
  (`v2_public_search_index`, `v2_feed_rss_items`, `activity_log`, `v2_module_flags`).
- Não cobre dados de seed de outros módulos usados em links (Homepage).
- Não define política de retenção de currículos: não há expurgo automático. A
  reimplementação MUST definir prazo de guarda (ex.: 180 dias após encerramento da vaga
  ou decisão final), expurgar arquivo e linha/anonimizar por comando agendado, auditar o
  expurgo e registrar consentimento explícito do candidato (ver `spec.md` §3.4).
- Não há `metadata` em `v2_job_applications`, apesar do model declarar (§3.4). Se o
  consentimento for persistido, essa coluna (ou uma tabela dedicada) precisa existir.

## 2. Abordagem

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

Schema aditivo por migrations corretivas: a primeira cria o núcleo, duas migrations de
2026 acrescentam as colunas que o código já usava e uma desliga a flag. Não há
normalização de status (enum textual) nem de mídia (UUID sem FK). A integridade é feita
por validação de aplicação (mimes/tamanho) e por hooks.

```mermaid
flowchart LR
  A[JobOpeningController] -->|create/update| T1[(v2_job_openings)]
  A -->|delete| T1
  P[TrabalheConoscoController.apply] -->|insert| T2[(v2_job_applications)]
  P -->|store| F[(storage/app/resumes)]
  J[JobApplicationController] -->|status/delete| T2
  J -->|delete file| F
  T1 -->|saved| S[(v2_public_search_index)]
  T1 -->|saved| R[(v2_feed_rss_items)]
  T1 -->|deleted| S
  T1 -->|deleted| R
  A -->|log| L[(activity_log)]
  J -->|log| L
  M[migration flag] --> FL[(v2_module_flags)]
```

### 2.2 Mecânica passo a passo

1. `2025_01_01_000006` cria `v2_job_openings` dentro de transação, com
   `SET lock_timeout='3s'`, índices nomeados e rollback que **renomeia** a tabela para
   `backup_v2_job_openings_<timestamp>` em vez de dropar (`:16-55,62-90`).
2. `2025_01_01_000007` cria `v2_job_applications`, FK `fk_v2_job_applications_opening`
   com `onDelete('cascade')` e três índices (`:16-57`).
3. `2026_08_11_000001` adiciona `description_detailed` (texto, comentário “AST JSON do
   ContentRichEditor (Tiptap)”) e `metadata` (jsonb, “SEO data + metadados flexíveis”)
   (`:26-31`).
4. `2026_08_11_000002` adiciona `cover_image_id` e `attachment_pdf_id` (`string(36)`),
   sem FK, comentadas como UUID de `v2_gallery_media` (`:22-27`).
5. `2026_09_03_000001` insere ou atualiza a linha `trabalheconosco` em
   `v2_module_flags` com `active=false` (`:23-55`); `down()` só faz `active=true`
   (`:57-72`).
6. Em runtime, `JobOpening::creating` gera `id`/`slug`; `saving` garante unicidade;
   `saved` indexa/feed; `deleted` remove dos dois (`JobOpening.php:101-181`).
7. `JobApplication::creating` só gera `id` (`JobApplication.php:101-109`).

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

- **Migrations corretivas em vez de editar a original** `[C]`: preserva bancos já
  migrados; o custo é um schema com colunas mortas (`description`, `requirements`,
  `benefits`) e três migrations para o mesmo assunto.
- **UUID em PHP, não no banco** `[C]`: `v2_uuid()` (UUID v4) e `id` atribuído antes do
  `save`; não há default no banco (`v2_uuid` helper: `helpers.php:61-69`).
- **Sem FK para mídia** `[C]`: `cover_image_id`/`attachment_pdf_id` são `string(36)`
  validadas só por `max:36`; a integridade depende da Galeria.
- **Enum textual de status** `[C]`: `string(20)` com default `pending`; os valores válidos
  existem apenas em PHP (`JobApplication.php:54-69`), não há `CHECK` no banco.
- **FK cascade “decorativa”** `[C]`: soft delete não dispara cascade; a única forma de
  acionar seria `forceDelete()`, que o módulo não expõe.
- **Rollback preservando dados** `[C]`: `down()` renomeia para `backup_*` e loga
  `Log::alert`; não há dropar tabela em nenhuma migration do módulo.

### 2.4 Trechos de código pivô

```php
// V2/Modules/Publicacoes/TrabalheConosco/Migrations/2025_01_01_000006_create_v2_job_openings_table.php:23-44
// — schema efetivo da tabela de vagas (antes das três migrations corretivas).
Schema::create('v2_job_openings', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('title', 255);
    $table->string('slug', 255)->unique();
    $table->string('type', 50)->nullable();
    $table->string('workload', 100)->nullable();
    $table->string('location', 255)->nullable();
    $table->string('salary_range', 100)->nullable();
    $table->text('description')->nullable();
    $table->text('requirements')->nullable();
    $table->text('benefits')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamp('published_at')->nullable();
    $table->timestamp('closes_at')->nullable();
    $table->string('created_by', 36)->nullable();
    $table->timestamps();
    $table->softDeletes();

    $table->index('slug', 'idx_v2_job_openings_slug');      // redundante com unique
    $table->index('is_active', 'idx_v2_job_openings_active');
    $table->index('published_at', 'idx_v2_job_openings_published');
});
```

`unique()` já cria índice para `slug`; o índice nomeado adicional é redundante. O
`unique` em `slug` não considera `deleted_at`: uma vaga arquivada continua bloqueando o
slug, e o hook de unicidade também a considera (não filtra soft-deleted no `exists()`).

```php
// .../Migrations/2026_08_11_000001_add_description_detailed_and_metadata_to_v2_job_openings.php:26-31
// — o código já esperava estas colunas; a migration só alinha o banco.
Schema::table('v2_job_openings', function (Blueprint $table) {
    $table->text('description_detailed')->nullable()->after('salary_range')
          ->comment('AST JSON do ContentRichEditor (Tiptap)');
    $table->jsonb('metadata')->nullable()->after('description_detailed')
          ->comment('SEO data + metadados flexíveis');
});
```

`description_detailed` é `text`, não `jsonb`: o AST é gravado como string JSON e
decodificado em memória (`ContentRichEditorRendererService::ensureArray`). Um JSON
inválido não quebra o portal: o renderer devolve `[]` (`ContentRichEditorRendererService.php:28-35`).

```php
// .../Migrations/2025_01_01_000007_create_v2_job_applications_table.php:23-46
// — candidaturas: FK para a vaga, currículo NOT NULL e status textual.
Schema::create('v2_job_applications', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->uuid('job_opening_id');
    $table->string('name', 255);
    $table->string('email', 255);
    $table->string('phone', 50)->nullable();
    $table->string('linkedin_url', 255)->nullable();
    $table->text('cover_letter')->nullable();
    $table->string('resume_path', 255);
    $table->string('status', 20)->default('pending');
    $table->text('notes')->nullable();
    $table->string('reviewed_by', 36)->nullable();
    $table->timestamp('reviewed_at')->nullable();
    $table->timestamps();
    $table->softDeletes();

    $table->foreign('job_opening_id', 'fk_v2_job_applications_opening')
          ->references('id')->on('v2_job_openings')
          ->onDelete('cascade');

    $table->index('job_opening_id', 'idx_v2_job_applications_opening');
    $table->index('status', 'idx_v2_job_applications_status');
    $table->index('email', 'idx_v2_job_applications_email');
});
```

`resume_path` é `NOT NULL`: se o upload falhar e retornar `false`, o insert viola a
coluna (ver `LG-TRABALHECONOSCO-02`). `email` tem índice simples (busca/filtro futuro),
não unique — o mesmo e-mail pode candidatar-se várias vezes à mesma vaga.

```php
// V2/Modules/Publicacoes/TrabalheConosco/Models/JobOpening.php:41-61
// — o contrato de escrita/leitura do model.
protected $fillable = [
    'title', 'slug', 'type', 'workload', 'location', 'salary_range',
    'description_detailed', 'cover_image_id', 'attachment_pdf_id',
    'is_active', 'published_at', 'closes_at',
];

protected $casts = [
    'is_active'    => 'boolean',
    'published_at' => 'datetime',
    'closes_at'    => 'datetime',
    'metadata'     => 'array',
];

protected $dates = [ 'created_at', 'updated_at', 'deleted_at', 'published_at', 'closes_at' ];
```

`metadata`, `created_by` e `id` não são `fillable`; os controllers atribuem
`id`/`created_by` diretamente (atribuição não passa por `fill`). Consequência: qualquer
código que use `JobOpening::create([...])` com `metadata` ou `created_by` descarta os
campos silenciosamente. `$dates` repete `$casts` para `published_at`/`closes_at` — no
Laravel 5.5 o `$casts` já basta.

```php
// .../Models/JobOpening.php:201-213 — formato exato do payload de busca.
$dto = new PublicSearchDTO(
    $this->title,
    $this->slug,
    'trabalhe-conosco',
    get_class($this),                                  // entity_type = FQCN
    $this->id,
    str_limit(strip_tags($this->description_detailed ?: $this->title), 300),
    ['vaga', 'emprego', 'trabalhe-conosco'],
    strip_tags($this->description_detailed ?: $this->title)
);

app(PublicIndexerService::class)->index($dto);
```

O `summary_fragment` e o `search_deep_content` recebem **JSON cru** com tags removidas,
não o HTML renderizado — a busca full-text indexa chaves do AST (`"type"`, `"text"`,
`"attrs"`, URLs etc.), o que polui o ranking. O upsert usa
`ON CONFLICT (entity_type, entity_id)` com `id` novo a cada chamada, mas o `id` do
conflito **não** é atualizado (`PublicIndexerService.php:27-40`).

### 2.5 Algoritmos, consultas e custos

- Criação de vaga: 1 `INSERT` + N `SELECT exists` de slug + 1 upsert de busca + 1
  upsert de feed + 1 `INSERT activity_log` (quando via CMS).
- Atualização de vaga: 1 `SELECT` (`findOrFail`) + 1 `UPDATE` + os mesmos efeitos.
- `syncToSearch` inativa: 1 `DELETE` na busca + 1 `DELETE` no feed.
- Portal: 1 `SELECT` com `is_active`/`closes_at`, ordenado por `published_at`; usa
  índices `active` e `published`.
- Candidaturas: 1 `SELECT` paginado + 1 `SELECT` das vagas relacionadas (eager load).
- `JobApplicationController@index` carrega **todas** as vagas para o select de filtro
  (`JobApplication::all()` na view, `applications/index.blade.php:8`), sem paginação ou
  escopo — custo cresce com o catálogo.
- Contagem do dashboard: `JobOpening::open()->count()` e `JobApplication::pending()->count()`.

## 3. Particularidades

### 3.1 O que só existe aqui

- **Três gerações de schema na mesma tabela**: colunas legadas de texto (`description`,
  `requirements`, `benefits`) nunca removidas, mais AST e mídias.
- **Flag semeada por migration de módulo** com `module_key` lowercase e grupo
  `CONTEUDO`, diferente do grupo do provider.
- **Candidatura com arquivo obrigatório e sem nome original**: o schema não tem coluna
  para `original_name`, `mime` ou tamanho.
- **Índice único de slug ignorando soft delete** — arquivar não libera a URL.
- **FK cascade que nunca dispara** no fluxo normal.

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

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-TRABALHECONOSCO-D01` | `v2_job_openings.slug` é `unique` e gerado/verificado no model, sem filtrar soft-deleted. | Título repetido (mesmo de vaga arquivada) recebe sufixo. | `2025_01_01_000006...:26`; `JobOpening.php:116-126` |
| `RN-TRABALHECONOSCO-D02` | `id` é UUID v4 gerado em PHP; não há default no banco. | Insert sem `id` falha; `JobOpening`/`JobApplication` geram no `creating`. | `helpers.php:61-69`; `JobOpening.php:101-104`; `JobApplication.php:105-108` |
| `RN-TRABALHECONOSCO-D03` | `metadata` existe só em `v2_job_openings`; o model de candidaturas declara cast para coluna inexistente. | Escrita em `JobApplication->metadata` falha; não usada. | `2026_08_11_000001...:29-30`; `JobApplication.php:42-45` |
| `RN-TRABALHECONOSCO-D04` | `resume_path` é `NOT NULL` e não guarda metadados do arquivo. | Upload falho → erro de banco; download usa só o caminho. | `2025_01_01_000007...:31` |
| `RN-TRABALHECONOSCO-D05` | `job_opening_id` tem FK `cascade`, mas o delete é lógico. | Candidaturas sobrevivem à vaga arquivada; FK nunca é acionada. | `2025_01_01_000007...:39-41`; `JobOpeningController.php:213` |
| `RN-TRABALHECONOSCO-D06` | `status` é `varchar(20)` com default `pending` e sem constraint. | Valor inválido só é barrado pela camada de aplicação. | `2025_01_01_000007...:32`; `JobApplication.php:54-69` |
| `RN-TRABALHECONOSCO-D07` | `created_by`/`reviewed_by` são `varchar(36)` sem FK para `users`. | Usuário removido deixa UUID órfão; relação `belongsTo` retorna `null`. | `2025_01_01_000006...:37`; `2025_01_01_000007...:34` |
| `RN-TRABALHECONOSCO-D08` | A busca indexa `entity_type` = FQCN do model. | Renomear a classe do model cria duplicatas de índice e quebra a remoção. | `JobOpening.php:206`; `PublicIndexerService.php:66-72` |
| `RN-TRABALHECONOSCO-D09` | O item de feed usa `entity_type='job-opening'` e não inclui `image_url`. | RSS de vaga nunca tem imagem, mesmo com capa. | `JobOpening.php:146-157` |
| `RN-TRABALHECONOSCO-D10` | `down()` da migration de flag só faz `UPDATE ... active=true`. | Se a linha não existia, o rollback não cria nada. | `2026_09_03_000001...:57-72` |
| `RN-TRABALHECONOSCO-D11` | Rollback das tabelas renomeia para `backup_*` e mantém os dados. | `migrate:rollback` não libera espaço nem apaga dados. | `2025_01_01_000006...:62-90`; `2025_01_01_000007...:64-94` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Slug após arquivar**: a vaga soft-deleted mantém o slug ocupado; criar outra com o
  mesmo título gera `-1`. Restaurar a antiga não é possível pelo CMS.
- **`is_active` e `closes_at` divergentes**: vaga “Ativa” no CMS pode estar fora do
  portal (data vencida); vaga “Inativa” pode ter `closes_at` futuro.
- **Busca órfã**: desativar a vaga remove do índice, mas desativar a **flag** não; a
  busca pública continua retornando o link, que responde 404.
- **Feed com JSON**: `description`/`content` contêm o AST cru; leitores RSS exibem
  chaves.
- **Candidatura duplicada**: sem unique em `(job_opening_id,email)`, o mesmo candidato
  pode enviar N vezes; cada envio gera novo arquivo e novo e-mail.
- **Reenvio do POST**: a resposta é `200` (não redirect), então atualizar a página
  reenvia o formulário e cria outra candidatura.
- **Arquivo órfão**: falha ao apagar do disco é logada e o soft delete prossegue; o
  arquivo fica para sempre (não há rotina de limpeza).
- **Migração fora de ordem**: `2026_09_03_000001` depende de `v2_module_flags`
  (`2026_06_30_000002`); em banco parcialmente migrado, a migration falha e dá rollback.
- **`jsonb` vs `text`**: `metadata` aceita JSON inválido? Não — `jsonb` rejeita; o
  controller não escreve `metadata`, então só o seeder (array PHP) chega lá.

### 3.4 Armadilhas de reimplementação

- **Declarar `metadata` em aplicações**: a coluna não existe; copiar o model sem corrigir
  a migration gera erro em qualquer `update`.
- **Usar `JobOpening::create()`**: `metadata`/`created_by`/`id` não são fillable. O padrão
  do projeto é `new Model(); $model->id = v2_uuid(); ...; $model->save();`.
- **Confiar na FK cascade**: o fluxo usa soft delete; apagar candidaturas exige ação
  explícita.
- **Trocar `text` por `jsonb` em `description_detailed`**: o renderer aceita string JSON,
  mas o restante do código (views, `strip_tags`) espera string; migrar exigiria ajustar
  acesso.
- **Índice único de slug com soft delete**: se a reimplementação filtrar
  `whereNull('deleted_at')` na checagem, o insert pode violar o unique do banco.
- **Datas**: `published_at`/`closes_at` são `timestamp` sem timezone; o controller
  concatena `' 00:00:00'` e o `scopeOpen` compara com `now()` (timezone da aplicação).

### 3.5 Divergências documentação × código e dívidas

- Docblock de `JobApplication` lista `metadata`; a tabela não tem a coluna
  (`JobApplication.php:14-27` vs migration `:23-46`).
- Migration de flags semeia 7 módulos, nenhum `trabalheconosco`; a linha é criada só
  pela migration de desativação (`2026_06_30_000002...:61-84`).
- Comentários das migrations corretivas grafam “TrabahoConosco”
  (`2026_08_11_000001...:11`; `2026_08_11_000002...:11`).
- `idx_v2_job_openings_slug` duplica o índice implícito do `unique`.
- `v2_job_openings.description/requirements/benefits` são colunas mortas: nenhum
  controller as escreve; só o fallback da listagem do portal lê `description`.

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `JobOpening` | model | PK string, `SoftDeletes`, hooks | tabela migrada | controllers, dashboard, seeder, galeria |
| `JobOpening::$fillable` | propriedade | 12 campos (sem id/metadata/created_by) | — | `create()`/`fill()` externos |
| `JobOpening::open()` | scope | `is_active` + `closes_at` | — | portal, dashboard |
| `JobOpening::syncToSearch()` | método | `void` | model com id | hook `saved` |
| `JobApplication` | model | PK string, `SoftDeletes` | tabela migrada | controllers, dashboard, seeder |
| `JobApplication::STATUS_*` | constantes | 5 strings | — | controllers, views |
| `PublicIndexerService::index/remove` | serviço | `index(PublicSearchDTO)`, `remove(string $class, string $id)` | tabela de índice | hooks |
| `FeedSender::forModule('trabalhe-conosco')` | serviço | `pushFromArray(array)`, `remove(string,string)` | tabela de feed | hooks |

### 4.2 Contrato de UI/JS (quando aplicável)

Não se aplica: não há componente Blade próprio para dados; os componentes usados
(`content-rich-editor`, `media-input`) pertencem a `SVC-CONTENTRICHEDITOR`/`SVC-MEDIA`. O
único contrato de dados na UI é o input hidden `description_detailed` (string JSON) e os
inputs hidden `cover_image_id`/`attachment_pdf_id` (UUID em `string(36)`).

### 4.3 Ordem e ciclo de vida

1. `2025_01_01_000006` (openings) deve rodar antes de `2025_01_01_000007` (applications)
   por causa da FK — garantido pelo nome.
2. `2026_08_11_000001` e `000002` exigem a tabela de openings.
3. `2026_09_03_000001` exige `v2_module_flags` (`2026_06_30_000002`, do módulo
   SupportConfigs).
4. Nenhuma migration semeia vagas; conteúdo demo vem do `DemoContentSeeder`.
5. Soft delete pode ser desfeito por `restore()` no model, mas não há rota/UI.

### 4.4 Receitas de uso

**1. Ler vagas abertas para uma listagem externa:**

```php
$vagas = \V2\Modules\Publicacoes\TrabalheConosco\Models\JobOpening::open()
    ->orderByDesc('published_at')
    ->get();
// soft-deleted ficam fora automaticamente pelo global scope
```

**2. Gravar SEO de uma vaga (fora do CMS, que não tem campo):**

```php
$opening->metadata = ['seo' => [
    'title' => 'Vaga: ' . $opening->title,
    'description' => str_limit(strip_tags($opening->description_detailed), 160),
]];
$opening->save(); // metadata é cast array e não está em $fillable: atribua direto
```

**3. Indexar/desindexar manualmente após mudança em massa:**

```php
foreach (\V2\Modules\Publicacoes\TrabalheConosco\Models\JobOpening::withTrashed()->get() as $o) {
    $o->syncToSearch(); // indexa se ativa, remove se não
}
```

**4. Consultar candidaturas pendentes de uma vaga:**

```php
$pendentes = \V2\Modules\Publicacoes\TrabalheConosco\Models\JobApplication::pending()
    ->where('job_opening_id', $id)
    ->orderBy('created_at')
    ->get(['id', 'name', 'email', 'created_at']);
```

### 4.5 Anti-uso

- **`JobApplication::create([...])` sem `id`**: a PK não tem default; o insert falha.
- **Apagar vaga com `forceDelete()` esperando cascade**: apaga candidaturas fisicamente
  e deixa arquivos órfãos no disco, além de remover o histórico de auditoria por subject.
- **Contar candidaturas com `withCount` e ignorar**: a view atual faz uma query por linha;
  ao reimplementar, use `applications_count`.

### 4.6 Custos e limites da integração

- Sem unique em e-mail/candidatura: crescimento linear sem deduplicação.
- `resume_path` limitado a 255 caracteres.
- Sem retenção/expurgo de currículos no legado: a reimplementação MUST criar rotina
  agendada de expurgo (arquivo + linha/anonimização) com prazo documentado e log de
  auditoria, além de registrar consentimento explícito na candidatura (LGPD).
- Download de currículo MUST ser autenticado (`CmsAuthenticate` + permissão
  `TrabalheConosco`, autorização por candidatura) e servido do disco `local` com
  `Content-Disposition: attachment`, `nosniff` e `no-store`; nunca `/storage` público.
- Hooks fazem I/O síncrono (busca/feed) em toda escrita, inclusive seeds em massa.
- `v2_public_search_index` usa `search_vector` gerado (tsvector português) — o módulo só
  fornece texto; a ordenação/consulta é do serviço de busca.

## 5. Rotas e contratos HTTP

Não se aplica a este anexo: as rotas que escrevem esses dados estão em `rotas.md` e no
`spec.md` §5. Resumo de quem escreve: `store/update/destroy` (openings), `apply`
(applications), `applications/{id}/status` e `applications/{id}` (delete).

## 6. Dados e modelo de domínio

### 6.1 `v2_job_openings`

| Coluna | Tipo | Null | Default | Cast | Observação |
|---|---|---|---|---|---|
| `id` | uuid | não | — | — | PK; gerada por `v2_uuid()` no hook `creating` |
| `title` | varchar(255) | não | — | — | obrigatório no CMS |
| `slug` | varchar(255) | não | — | — | `unique`; derivado do título; imutável |
| `type` | varchar(50) | sim | null | — | texto livre (CLT/PJ/Estágio) |
| `workload` | varchar(100) | sim | null | — | texto livre |
| `location` | varchar(255) | sim | null | — | texto livre |
| `salary_range` | varchar(100) | sim | null | — | texto livre |
| `description` | text | sim | null | — | legado; só fallback de exibição |
| `requirements` | text | sim | null | — | legado, não escrito |
| `benefits` | text | sim | null | — | legado, não escrito |
| `is_active` | boolean | não | true | boolean | controla portal/busca/feed |
| `published_at` | timestamp | sim | null | datetime | ordenação do portal |
| `closes_at` | timestamp | sim | null | datetime | filtro de `scopeOpen` |
| `created_by` | varchar(36) | sim | null | — | UUID de `users`, sem FK |
| `created_at`/`updated_at` | timestamp | sim | — | datetime | — |
| `deleted_at` | timestamp | sim | null | datetime | soft delete |
| `description_detailed` | text | sim | null | — | AST JSON do editor (2026_08_11_000001) |
| `metadata` | jsonb | sim | null | array | SEO e metadados; fora de `$fillable` |
| `cover_image_id` | varchar(36) | sim | null | — | UUID de `v2_gallery_media`, sem FK |
| `attachment_pdf_id` | varchar(36) | sim | null | — | idem |

Índices: `idx_v2_job_openings_slug` (redundante com unique), `idx_v2_job_openings_active`,
`idx_v2_job_openings_published`, além do índice único de `slug`.

Quem escreve: `JobOpeningController@store/update` (todos os campos do formulário),
`JobOpening@creating/saving` (`id`, `slug`), `PopulateMissingImages` (`cover_image_id`),
`DemoContentSeeder` (todos + `metadata`), `JobOpening@delete` (`deleted_at`).

### 6.2 `v2_job_applications`

| Coluna | Tipo | Null | Default | Cast | Observação |
|---|---|---|---|---|---|
| `id` | uuid | não | — | — | PK; `v2_uuid()` no hook |
| `job_opening_id` | uuid | não | — | — | FK `fk_v2_job_applications_opening` → `v2_job_openings.id`, `cascade` |
| `name` | varchar(255) | não | — | — | obrigatório |
| `email` | varchar(255) | não | — | — | validado por `email`; indexado, não unique |
| `phone` | varchar(50) | sim | null | — | opcional |
| `linkedin_url` | varchar(255) | sim | null | — | validado por `url` |
| `cover_letter` | text | sim | null | — | exibida com `nl2br(e(...))` |
| `resume_path` | varchar(255) | não | — | — | caminho relativo no disco `local` |
| `status` | varchar(20) | não | `pending` | — | 5 valores em PHP |
| `notes` | text | sim | null | — | sobrescrita a cada update |
| `reviewed_by` | varchar(36) | sim | null | — | UUID de `users`, sem FK |
| `reviewed_at` | timestamp | sim | null | datetime | atualizado sob condição |
| `created_at`/`updated_at` | timestamp | sim | — | datetime | — |
| `deleted_at` | timestamp | sim | null | datetime | soft delete |

Índices: `idx_v2_job_applications_opening`, `idx_v2_job_applications_status`,
`idx_v2_job_applications_email`. Não há `metadata` (ver D03).

### 6.3 `v2_module_flags` (linha do módulo)

| Coluna | Valor gravado | Fonte |
|---|---|---|
| `id` | `v2_uuid()` | migration `:35` |
| `module_key` | `trabalheconosco` | `:21,36` |
| `module_title` | `Trabalhe Conosco` | `:37` |
| `active` | `false` | `:38` |
| `module_group` | `CONTEUDO` | `:39` |
| `created_at`/`updated_at` | `now()` | `:40-41` |
| `deleted_at`/`transaction_snapshot_id` | `null` (não informados) | schema `2026_06_30_000002...:50-54` |

Leitura: `ModuleFlagService::getAllFlags` (`whereNull('deleted_at')`, `module_key→active`)
e `isActive('TrabalheConosco')` (match lowercase) (`ModuleFlagService.php:42-95`).

### 6.4 Modelos

`JobOpening` (`JobOpening.php:35-240`): `$table='v2_job_openings'`,
`incrementing=false`, `keyType='string'`, `SoftDeletes`; relações `applications`
(`hasMany`), `creator` (`belongsTo App\Admin\User`), `coverImage`/`attachmentPdf`
(`belongsTo GalleryMedia`); hooks `creating` (id/slug), `saving` (unicidade), `saved`
(busca + feed), `deleted` (remoção); `syncToSearch`; scopes `active`, `open`.

`JobApplication` (`JobApplication.php:30-130`): `$table='v2_job_applications'`,
`incrementing=false`, `keyType='string'`, `SoftDeletes`; constantes de status,
`$validStatuses`, `$statusLabels`; relações `jobOpening`, `reviewer`; hook `creating`
(id); scopes `byStatus`, `pending`.

```mermaid
erDiagram
  v2_job_openings {
    uuid id PK
    varchar slug UK
    varchar title
    boolean is_active
    timestamp published_at
    timestamp closes_at
    jsonb metadata
    varchar cover_image_id
    varchar attachment_pdf_id
    timestamp deleted_at
  }
  v2_job_applications {
    uuid id PK
    uuid job_opening_id FK
    varchar name
    varchar email
    varchar resume_path
    varchar status
    varchar reviewed_by
    timestamp reviewed_at
    timestamp deleted_at
  }
  v2_job_openings ||--o{ v2_job_applications : "job_opening_id"
```

### 6.5 Tabelas de efeito

- `v2_public_search_index`: colunas usadas `id,title,slug,module,entity_type,entity_id,
  summary_fragment,search_deep_content,tags,created_at,updated_at`; conflito por
  `(entity_type,entity_id)` (`PublicIndexerService.php:27-40`).
- `v2_feed_rss_items`: `module,entity_type,entity_id,title,link,description,content,
  author,categories,image_url,enabled,published_at`; `updateOrCreate` por
  `(entity_type,entity_id)` e `enabled=true` (`FeedRssService.php:39-60`).
- `activity_log`: `log_name`, `description`, `subject_type/subject_id`, `causer_*`,
  `properties` JSON com `v2_audit`, IP e user-agent (`ActivityLoggerService.php:20-38`).

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> rascunho: criar com is_active=false
  [*] --> publicada: criar com is_active=true
  rascunho --> publicada: update is_active=true
  publicada --> rascunho: update is_active=false
  publicada --> vencida: closes_at < now()
  vencida --> publicada: update closes_at futuro
  publicada --> arquivada: delete (soft)
  rascunho --> arquivada: delete (soft)
  vencida --> arquivada: delete (soft)
  arquivada --> [*]
```

“Rascunho/vencida” são estados derivados; não há coluna. O CMS mostra badge só por
`is_active`.

## 8. UI CMS

Não se aplica a este anexo (ver `cms.md`). O contrato de dados visível é: datas formatadas
`d/m/Y`, `published_at`/`closes_at` editadas apenas como data (`Y-m-d`), status como
select dos 5 valores, notas como textarea livre.

## 9. UI Portal

Não se aplica a este anexo (ver `portal.md`). O portal lê `title`, `slug`, `type`,
`workload`, `location`, `salary_range`, `description_detailed`, `coverImage`,
`attachmentPdf`, `published_at` e `metadata['seo']`.

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

- Não há chave de config própria do módulo; a única configuração que altera dados é
  `v2.support.email` (destino do e-mail, não grava).
- A flag é dado, não config PHP: `v2_module_flags.module_key='trabalheconosco'`.
- Ausência de linha na tabela ⇒ módulo **ativo** (`ModuleFlagService::isActive` retorna
  `true` quando não encontra); a desativação depende da linha criada pela migration.
- `down()` reativa por update; não remove a linha.

## 11. Observabilidade, erros e limites

- **Logs de migration**: `Log::info("[Migration] Tabela 'v2_job_openings' criada com
  sucesso.")`, `Log::info("[Migration] Tabela 'v2_job_applications' criada com sucesso.")`,
  `Log::info("[Migration] Colunas 'description_detailed' e 'metadata' adicionadas...")`,
  `Log::info("[Migration] Colunas 'cover_image_id' e 'attachment_pdf_id' adicionadas...")`,
  `Log::error('ERRO AO DESATIVAR MODULO trabalheconosco: ...')` (`Migration` files).
- **Rollback**: `Log::alert("[Migration] Tabela 'v2_job_openings' preservada como
  '{backup}'.")` e idem para applications.
- **Auditoria**: somente escritas do CMS; seeder, comando de capas e flag não auditam.
- **Erros de escrita**: `JobOpeningController` captura exceção de `save()` e mostra
  `Erro ao criar vaga. Tente novamente.` / `Erro ao atualizar vaga. Tente novamente.`;
  `JobApplicationController` **não** captura, então falha de status vira 500.
- **Limites físicos**: `resume_path` 255; `name/email/linkedin_url` 255; `phone` 50;
  `type` 50; `workload/salary_range` 100; `notes` 2000 (validação) mas `text` no banco.

## 12. Seeds e dados iniciais

| Seeder/migration | Tabelas | Registros | Idempotência | Ordem | Fonte |
|---|---|---|---|---|---|
| `2026_09_03_000001_disable_trabalhe_conosco_module_flag` | `v2_module_flags` | 1 linha `trabalheconosco` (`active=false`) | sim: `exists()` → `insert` senão `update` | após `2026_06_30_000002` | migration `:23-55` |
| `DemoContentSeeder::seedVagas` | `v2_job_openings`, `v2_job_applications` | 6 vagas + 5 candidaturas | sim: pula se `JobOpening::count() > 0` | dentro de `run()` (`:58`), só fora de produção | `DemoContentSeeder.php:1037-1210`; `DatabaseSeeder.php:102-107` |
| `PopulateMissingImages::populateVagas` | `v2_job_openings.cover_image_id` | vagas sem capa | por filtro `whereNull('cover_image_id')` | comando manual | `PopulateMissingImages.php:173-186` |

Detalhes das vagas demo: 6 títulos (Full Stack, Dados, Segurança, Estágio, Gerente,
Product Owner), a última criada inativa e com `closes_at` no passado
(`DemoContentSeeder.php:1155-1163`); candidaturas com status variados e `resume_path`
apontando para arquivos **inexistentes** (`:1189`).

## 13. Lacunas

- `LG-TRABALHECONOSCO-02` (repetida do `spec.md`) — destino físico do currículo e acesso:
  não há symlink nem rota de download; o link do CMS pode responder 404. A
  reimplementação MUST servir por rota autenticada (permissão `TrabalheConosco`,
  autorização por candidatura, `Storage::disk('local')->download()`, `attachment`,
  `nosniff`, `no-store`) ou URL assinada curta; **nunca** disco público.
- `LG-TRABALHECONOSCO-D07` — integridade de `created_by`/`reviewed_by`: sem FK, não é
  possível saber se usuários excluídos deixam UUIDs órfãos em produção.
- `LG-TRABALHECONOSCO-D08` — volume de `v2_job_openings`/`v2_job_applications` e tempo de
  retenção de currículos não são determináveis pelo código.
- `LG-TRABALHECONOSCO-D09` — colunas legadas (`description`, `requirements`,
  `benefits`): não se sabe se há dados históricos em produção; o código não as escreve.
- `LG-TRABALHECONOSCO-D10` — `metadata` de candidaturas: possível migration perdida;
  procurar no histórico do repositório/banco.

## 14. Checklist de paridade

- [ ] `PC-TRABALHECONOSCO-21` — Criar vaga → `id` UUID v4, `slug` único e `is_active=true`; `created_by` = UUID do usuário Sentinel. Fonte: `JobOpeningController.php:89-105`; `JobOpening.php:101-104`.
- [ ] `PC-TRABALHECONOSCO-22` — Salvar vaga com título que colide com vaga arquivada → sufixo `-1` (o unique do banco não libera o slug). Fonte: `JobOpening.php:116-126`; migration `:26`.
- [ ] `PC-TRABALHECONOSCO-23` — Inserir vaga com `is_active=false` → linha ausente de `v2_public_search_index` e de `v2_feed_rss_items`; com `true` → presentes. Fonte: `JobOpening.php:130-163`.
- [ ] `PC-TRABALHECONOSCO-24` — Soft delete de vaga → `deleted_at` preenchido e remoção das duas tabelas de efeito. Fonte: `JobOpening.php:166-181`.
- [ ] `PC-TRABALHECONOSCO-25` — Renomear vaga não altera `slug` nem `entity_id` do índice. Fonte: `JobOpening.php:112`; `PublicIndexerService.php:27-40`.
- [ ] `PC-TRABALHECONOSCO-26` — Candidatura válida → `status='pending'`, `reviewed_by`/`reviewed_at` nulos, `resume_path` não vazio. Fonte: `TrabalheConoscoController.php:95-105`.
- [ ] `PC-TRABALHECONOSCO-27` — Candidatura sem currículo → erro de validação e nenhuma linha criada. Fonte: `TrabalheConoscoController.php:84-91`.
- [ ] `PC-TRABALHECONOSCO-28` — Excluir vaga não remove candidaturas (soft delete). Fonte: `JobOpeningController.php:213`; migration `2025_01_01_000007...:39-41`.
- [ ] `PC-TRABALHECONOSCO-29` — Excluir candidatura remove o arquivo do disco `local` e faz soft delete. Fonte: `JobApplicationController.php:118-126`.
- [ ] `PC-TRABALHECONOSCO-30` — Migration de flag: primeira execução insere; segunda atualiza sem duplicar (`module_key` único). Fonte: `2026_09_03_000001...:28-47`; `2026_06_30_000002...:38`.
- [ ] `PC-TRABALHECONOSCO-31` — `isActive('TrabalheConosco')` encontra a linha lowercase e retorna `false`. Fonte: `ModuleFlagService.php:76-84`.
- [ ] `PC-TRABALHECONOSCO-32` — `metadata['seo']` gravado aparece como metatags no detalhe (title sobrescrito pelo controller). Fonte: `SEOService.php:29-58`; `TrabalheConoscoController.php:67-68`.
- [ ] `PC-TRABALHECONOSCO-33` — Rollback das tabelas renomeia para `backup_*` e preserva dados. Fonte: `2025_01_01_000006...:62-90`.

## 15. Evidências consultadas

- `V2/Modules/Publicacoes/TrabalheConosco/Migrations/2025_01_01_000006_create_v2_job_openings_table.php:16-90`
- `V2/Modules/Publicacoes/TrabalheConosco/Migrations/2025_01_01_000007_create_v2_job_applications_table.php:16-94`
- `V2/Modules/Publicacoes/TrabalheConosco/Migrations/2026_08_11_000001_add_description_detailed_and_metadata_to_v2_job_openings.php:20-58`
- `V2/Modules/Publicacoes/TrabalheConosco/Migrations/2026_08_11_000002_add_cover_and_pdf_to_v2_job_openings.php:16-54`
- `V2/Modules/Publicacoes/TrabalheConosco/Migrations/2026_09_03_000001_disable_trabalhe_conosco_module_flag.php:19-72`
- `V2/Modules/Publicacoes/TrabalheConosco/Models/JobOpening.php:1-240`
- `V2/Modules/Publicacoes/TrabalheConosco/Models/JobApplication.php:1-130`
- `V2/Modules/Publicacoes/TrabalheConosco/Controllers/{JobOpeningController.php:72-222,JobApplicationController.php:37-135,TrabalheConoscoController.php:80-137}`
- `V2/Core/Services/ModuleFlagService.php:17-95`
- `V2/Core/Helpers/helpers.php:61-69,259-267`
- `V2/Services/Search/Services/PublicIndexerService.php:21-72`
- `V2/Services/FeedRSS/Services/FeedRssService.php:39-84`
- `V2/Services/ActivityLogger/ActivityLoggerService.php:20-38`
- `V2/Services/SEO/Services/SEOService.php:29-58`
- `V2/Services/Storage/StorageService.php:32-45,130-133`; `V2/Services/Storage/Adapters/LocalAdapter.php:85-96`
- `V2/Modules/Support/SupportConfigs/Migrations/2026_06_30_000002_create_v2_module_flags_table.php:33-84`
- `V2/Seeds/Content/DemoContentSeeder.php:1037-1210`; `V2/Services/Gallery/Console/Commands/PopulateMissingImages.php:173-186`
- `config/filesystems.php:18-66`
