mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(S37): liderancas que aparecem nas pecas na Central de Conteudos (Closes #1303)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
This commit is contained in:
parent
ebe1a98e85
commit
b119d203aa
32 changed files with 20484 additions and 36 deletions
1
docs/changelog/2026-09-24-s37.md
Normal file
1
docs/changelog/2026-09-24-s37.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S37 — Central de Conteúdos: quem aparece na peça e a faceta pública "Lideranças" (2026-09-24):** a ficha C211 ganha, entre Temas e Cidade, o bloco "Quem aparece na peça" com os dois recortes: `leaders` (relationship hasMany → `leadership`) com snapshot readOnly `leaderNames` (só o nome de exibição, derivado no `beforeValidate`) e `publicFigures` (text hasMany, texto curado canonicalizado contra o catálogo novo `src/lib/publicFigureCatalog.ts`, derivado do roster S30 das 53 dobradinhas + personalidades por texto livre). A busca de liderança do picker é um server action gated (`canReadCommunicationCatalog`) com projeção fixa `{id, label}` — único bypass intencional documentado (o RBAC atual nega o `communicator`, que é quem edita a ficha) e os int tests asseguram o shape. O catálogo público (S27) ganha a faceta única `Lideranças` unindo os dois recortes numa opção por nome de exibição (`slugify`), derivada SÓ de peças publicadas (fail-closed: despublicar remove o nome na hora); o nome entra no `searchText` (busca por termo) e lidera o `metaLabel` do card, com título contextual e chip ativo ao lado no desktop. Migration aditiva `20260924_123940_add_content_piece_people` (tabelas `content_piece_texts`/`content_piece_rels`, sem backfill, sem `ALTER TYPE`; `content_piece_curated_fields` intocado — o job nunca escreve os campos novos). Zero Consent/collection novos; o read público nunca seleciona `leaders` (depth 1 vazaria Contact). Verificação: `pnpm gate:fast` verde (lint + typecheck + unit) e `pnpm test:int` full verde (941 passed, 2 skipped); e2e local `frontendConteudos` 8/8 e `campaignSpeechAcervo` (bloco C211) 31/31; design certificado PARIDADE pelo `designer` (crítica final contra o app renderizado, cenas 01/01A/02/03).
|
||||
251
docs/plans/central-conteudos-liderancas-impl.md
Normal file
251
docs/plans/central-conteudos-liderancas-impl.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Impl: S37 — Central de Conteúdos — lideranças que aparecem nas peças (propriedade e filtro)
|
||||
|
||||
Status: aprovado (modo `--auto`) — hard-stops de schema/URL público pendentes de aprovação humana explícita
|
||||
Atualizado em: 2026-09-24
|
||||
Issue: #1303
|
||||
Intenção: docs/plans/central-conteudos-liderancas.md
|
||||
Design UI (gate): docs/plans/central-conteudos-liderancas-ui-design.html
|
||||
Appetite restante: herdado (~2 dias eng) — **uma propriedade nova na ficha (C211) + uma faceta nova no catálogo (S27)**, sem fluxo novo. Corte explícito para caber: catalogação automática intocada (sem sugestão de nomes), sem filtro/coluna na lista interna, sem e2e de browser do picker (unit de componente + int + e2e público cobrem), personalidades por texto livre (a semente do catálogo é o roster S30).
|
||||
|
||||
> Aprovado pelo próprio agente via `--auto` (modo autônomo do `work-issue`); a pausa do GATE virou apresentação-no-chat. Os hard-stops abaixo seguem exigindo aprovação humana explícita — `--auto` aprova o plano, não o schema nem o contrato público.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a ficha da peça registra dois recortes de "quem aparece" — lideranças da campanha (registros existentes) e figuras públicas (texto curado com catálogo) — e o catálogo público ganha **uma** faceta `Lideranças` que une os dois, mostrando só nomes de quem aparece em peça publicada (fail-closed); o nome entra na busca por termo e ninguém vê contato, território, status ou votos.
|
||||
- **O que NÃO negociar:**
|
||||
- Fail-closed de exposição: a faceta deriva **só** de peças publicadas; despublicar a última peça com um nome remove o nome na hora (tag `contentPieces` já busta em todo write).
|
||||
- Nada de dado interno: sem telefone, e-mail, municípios, organizações, `supportStatus`, `estimatedVotes`; leader lockdown e `Consent`/LGPD intocados.
|
||||
- Sem cadastro paralelo de pessoa: liderança = registro existente (`leadership` → `contact`); figura pública = texto + catálogo (molde `institution`).
|
||||
- Uma faceta só (decisão literal do humano: "Os dois"); curadoria humana manda — nada de a catalogação sobrescrever/decidir por gente.
|
||||
- Nomes só de exibição: sem contagem, ranking, página por pessoa.
|
||||
- **O que reavaliar (hipóteses da "Direção no codebase"):**
|
||||
- "A catalogação sugere" — **não no v1**: menção no transcript ≠ aparição na peça; sugerir nomes por match textual poluiria uma lista pública (D5 rejeita A/C e mantém `contentPieceCataloging.ts` intocado).
|
||||
- "A faceta pública deriva das peças" — confirmado, mas a denormalização precisa **morar na peça**: o read público (`contentPieceReads.ts:26-43`) tem `select` explícito e não pode ganhar a relação (depth 1 vazaria `Contact`). Entra um snapshot de **nomes** na própria peça (D1/D3).
|
||||
- "A ficha marca a liderança existente" — o access real nega `communicator` (`canReadLeadership` → `resolveProfileScopedRead`, `src/utilities/access/leaderships.ts:31-32`), que é justamente quem edita a ficha; sem um caminho gated a persona principal fica cega (D1).
|
||||
- "Figuras públicas no molde de Instituição" — o molde é **texto + catálogo**, sem relação com `stateDeputy`; a semente vem do `stateDeputyCatalog` (S30) para não duplicar as 53 linhas (D2).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FICHA["/campanha/comunicacao/conteudos/[id]\nContentPieceForm · 'Quem aparece na peça'"] --> LP["Lideranças da campanha\nbusca async (server action gated)\nprojeção {id, nome}"]
|
||||
FICHA --> FP["Figuras públicas\ncatálogo S30 + texto livre\ncanonicaliza grafia"]
|
||||
LP --> REL["contentPiece.leaders (relationship hasMany)"]
|
||||
FP --> TXT["contentPiece.publicFigures (text hasMany)"]
|
||||
REL --> HOOK["beforeValidate\nsnapshot leaderNames (só nomes) + searchText"]
|
||||
TXT --> HOOK
|
||||
HOOK --> PUB["publish (kill switch)\nrevalidateTag contentPieces"]
|
||||
PUB --> READS["contentPieceReads select explícito\nleaderNames + publicFigures\nNUNCA leaders/Contact"]
|
||||
READS --> VM["toContentPiecePublicItem\npeople + metaLabel"]
|
||||
VM --> FACET["faceta única 'lideranca'\nunião por slugify(nome)"]
|
||||
VM --> SEARCH["searchText normalizado\nq acha pelo nome"]
|
||||
FACET --> BOARD["/conteudos?lideranca=…"]
|
||||
PUB -. "despublica a última peça do nome" .-> FACET
|
||||
```
|
||||
|
||||
**Opções consideradas (abordagem geral):** A) relationship `leaders` + snapshot público de nomes na peça + busca interna gated com projeção mínima + figuras em texto/catálogo derivado do S30 + faceta única derivada dos nomes publicados; B) tudo texto livre na peça (liderança e figura), sem vínculo nem catálogo; C) relação direta com `stateDeputy` para as figuras e liberar `canReadLeadership` para a comunicação.
|
||||
**Recomendação:** **A** — preserva o dono de cada mecanismo (registro de Liderança, roster S30, contrato público do S27, hook de denormalização do C211), mantém a fronteira público→interno intacta (o read público nunca toca `leadership`/`contact`) e cabe no appetite sem collection nova.
|
||||
**Rejeitadas:** **B** porque perde o vínculo com o registro existente e o casamento de grafias (Q3-B/C do planejamento já rejeitadas); **C** porque a relação com `stateDeputy` só cobre as dobradinhas (exclui personalidades), carrega access/PII de campanha para o read público e ampliar o RBAC da liderança inteira por um picker de nomes é blast radius desproporcional (D1).
|
||||
|
||||
### D1 — Referência interna à liderança e o access do `communicator` (caro)
|
||||
|
||||
**Opções:** A) campo `leaders` (relationship hasMany → `leadership`) + snapshot readOnly `leaderNames` derivado por hook + loader de busca **gated com bypass intencional e documentado**, projeção fixa `{id, nome}`; B) só texto de nomes (`leaderNames` texto livre), sem relação; C) relação com busca via `overrideAccess: false` e picker restrito a coordinator/candidate; D) ampliar `canReadLeadership` para incluir `communicator`.
|
||||
**Recomendação:** **A** — o campo relacional é o registro existente (anti-cadastro-paralelo) e o snapshot resolve a fronteira pública sem join. A busca interna é a **única** leitura nova: novo `src/utilities/content/contentPieceLeaderOptions.ts` (server-only) com
|
||||
|
||||
- `searchContentPieceLeaderOptions(payload, actor, query)` e `resolveContentPieceLeaderOptions(payload, actor, ids)` → `{ id: number; label: string }[]`;
|
||||
- gate `canReadCommunicationCatalog(actor.role)` (`src/lib/campaignRoles.ts:27-28`) fail-closed (negado devolve `[]`/lança conforme o caller);
|
||||
- `isContactSearchQueryReady` (mín. 2 chars, `src/lib/contactSearchQuery.ts:9`) e teto `CONTENT_PIECE_LEADER_SEARCH_LIMIT = 20`;
|
||||
- query `payload.find({ collection: 'leadership', where: { 'contact.name': { contains: trimmed } }, depth: 1, select: { contact: true }, overrideAccess: true })` com **comentário de bypass intencional** ("só o nome de exibição sai deste módulo; nenhum outro campo de `leadership`/`Contact` é lido, retornado, logado ou serializado") — mesmo precedente de bypass documentado da geografia (`ContentPiece.ts:175-178`), não uma concessão de RBAC;
|
||||
- o mapeamento devolve somente `{ id, label }`; ids sem nome resolvido são descartados.
|
||||
O snapshot `leaderNames` é derivado no `beforeValidate` (`deriveContentPieceCatalogIndex`, `ContentPiece.ts:147-196`) **só quando `data.leaders !== undefined`** (`relationshipId` normalizado, ordem preservada; nomes em falta são pulados) e reusa o mesmo módulo com bypass documentado (contexto de sistema). O hook não marca os campos como curados (D5).
|
||||
**Rejeitadas:** **B** porque "marcar a liderança" vira texto sem lastro no dono e não distingue "liderança que existe" de "nome digitado"; **C** porque bloqueia a persona que edita a ficha (o `communicator` é quem opera a Central, `requireCampaignPageActor({ gate: 'communicationCatalog' })` + `canReadContentPiece`) — contraria o outcome; **D** porque concede a leitura inteira de `leadership` (municípios, `supportStatus`, `user`, `consent`, PII de `Contact` via depth) para a comunicação por causa de um picker, mexe no dono do RBAC e amplia a superfície de vazamento sem necessidade.
|
||||
|
||||
### D2 — Recorte de figuras públicas: storage e catálogo/semente (caro)
|
||||
|
||||
**Opções:** A) `publicFigures` (`text hasMany`, valores canônicos/curtos) + catálogo estático novo `src/lib/publicFigureCatalog.ts` **derivado** do `stateDeputyCatalog` (S30) + lista explícita (vazia no v1) de personalidades, com canonicalização de grafia no save; B) um único `text` (molde `institution` literal); C) `relationship hasMany` → `stateDeputy`; D) texto livre sem catálogo.
|
||||
**Recomendação:** **A** — a peça costuma ter mais de uma pessoa (dobradinha + personalidade) e o design desenha chips com lista sugerida ("Digite ou escolha uma figura pública…"). O catálogo:
|
||||
|
||||
- `PublicFigureKind = 'dobradinha' | 'personalidade'`; entradas `{ slug, name, kind }`;
|
||||
- `publicFigureCatalog = stateDeputyCatalog.map(...)` com `slug: 'estadual-<slug>'` (prefixo evita colisão com personalidades; as 53 linhas **não** são copiadas — o roster S30 é o dono) `+ PUBLIC_FIGURE_PERSONALITIES` (constante explícita, **vazia no v1**: inventar nomes seria dado de produto sem lastro; "outras personalidades" entram por texto livre e viram data edit quando valer catalogar);
|
||||
- `filterPublicFigures(query)` para o picker (accent/case-insensitive sobre `name`/`slug`, como `filterStateDeputyCards`, `src/lib/stateDeputyCatalog.ts:115-122`);
|
||||
- `resolvePublicFigureName(value)` — fold accent/case/pontuação com `normalizeSearchPhrase` (`src/lib/wordStartFilter.ts:1-8`) sobre `slug`+`name`; ambíguo → `null`;
|
||||
- `normalizeContentPiecePublicFigures(values)` — trim, `resolvePublicFigureName(v) ?? v`, dedupe por forma normalizada, teto `CONTENT_PIECE_PUBLIC_FIGURES_MAX`.
|
||||
A canonicalização roda na action (`updateContentPieceForActor`) **e** no hook (gravações diretas/admin passam pelo mesmo filtro), reusando `normalizeForSearch`/`slugify` do contrato.
|
||||
**Rejeitadas:** **B** porque força protocolo de separador e transforma os chips em uma string (a faceta e os chips precisam de itens); **C** porque só cobre as dobradinhas (personalidades ficariam de fora), traria o access de `stateDeputy` para o caminho público e criaria duas fontes para "figura pública" (roster + catálogo); **D** porque perde o casamento de grafias (Q3-C já rejeitada no planejamento).
|
||||
|
||||
### D3 — A faceta pública une os dois recortes sem ler `leadership`/`Contact` (caro)
|
||||
|
||||
**Opções:** A) uma faceta `lideranca` (param slug, pt-BR, como `instituicao`) derivada em memória da união `leaderNames ∪ publicFigures` **dos itens publicados**, chave `slugify(nome)`, label = nome exibido; B) duas facetas (`lideranca` + `figuras`); C) chave por id/slug de origem.
|
||||
**Recomendação:** **A** — o read público seleciona somente `leaderNames`/`publicFigures` da própria peça (`contentPieceReads.ts`, `select` explícito; **nunca** `leaders`), então a faceta só existe a partir do que está publicado (fail-closed por construção, sem query de publicação extra). A união vive no contrato puro `src/lib/contentPieceCatalog.ts` (`contentPiecePublicPeople(item)` → `{ slug, name }[]`, dedup por `slugify`), consumida por `contentPieceCatalogFacets` e por `filterContentPieceCatalogItems` (AND com as demais facetas, como hoje). Homônimos colapsam no mesmo filtro por design: a faceta é um filtro de **nome de exibição**, não de identidade — documentado no código e pinado em unit (o mesmo `slug` para "Dra. Elaine"/"Dra Elaine" vira uma opção só; o label vence o último item na ordem estável).
|
||||
**Rejeitadas:** **B** porque contraria o literal do humano ("Os dois" na mesma faceta) e duplicaria a superfície de filtro; **C** porque ids de liderança vazariam identidade interna na URL pública, não existem para figuras e quebrariam o contrato de slug do S27.
|
||||
Extras de UI cobertos pelo design (cena 03): caption "Em peças publicadas" dentro do dropdown da faceta `Lideranças` e placeholder da busca "…assunto, cidade ou **pessoa**…".
|
||||
|
||||
### D4 — Nome na busca e no card público (caro)
|
||||
|
||||
**Opções:** A) nomes entram no `searchText` (type + chamada) e o `metaLabel` do card lidera com o primeiro nome; B) nomes só na faceta, fora da busca/card; C) linha própria de "quem aparece" no card e/ou na página da peça.
|
||||
**Recomendação:** **A** — o aceite é explícito ("buscar o nome de quem aparece acha a peça") e o design desenha o nome na linha de metadados do card ("Nome da liderança · Tema"). Mudanças: `contentPieceSearchText` (`src/lib/contentPiece.ts:323-336`) ganha `leaderNames`/`publicFigures` no input (normalizados) com o call site atualizado (`ContentPiece.ts:187-194`, com fallback `originalDoc` como cidade/região); `metaLabel` (`contentPieceCatalog.ts:524-526`) passa a `[primeiroNome, tema/institution, local]` — card **sem** pessoa permanece byte-idêntico (os pins do S27/S28 seguem). A página da peça **não** ganha linha nova no v1 (o artefato cena 04 do S27 não a cobre) — vira extensão de design se o gate quiser.
|
||||
**Rejeitadas:** **B** porque contraria o aceite; **C** porque é markup sem design (gap com trigger (a), não invenção do implementador).
|
||||
|
||||
### D5 — Colisão com a regra `curated` da catalogação automática (caro)
|
||||
|
||||
**Opções:** A) acrescentar `leaders` e `publicFigures` ao enum persistido `content_piece_curated_fields`; B) **não** acrescentar — o vocabulário continua exatamente os campos que o job pode preencher, e a action não marca os novos; C) acrescentar só `publicFigures` "para o futuro sugeridor".
|
||||
**Recomendação:** **B** — `curatedFields` existe para arbitrar job×curadoria (`contentPieceJob.ts` só escreve campo fora da lista); como o job **nunca** escreve os campos novos (nomes são curadoria manual; menção ≠ aparição), incluí-los seria vocabulário morto e obrigaria um `ALTER TYPE enum_content_piece_curated_fields` sem consumidor. `contentPieceCataloging.ts` e `content_piece_curated_fields` ficam **intocados**.
|
||||
**Rejeitadas:** **A** e **C** porque criam estado/enum sem escritor (a "sugestão" da intenção é permissiva, não requisito); **gatilho:** se um sugeridor de figuras for aprovado, o item dele adiciona o valor do enum na própria migration, junto do matcher.
|
||||
|
||||
### D6 — UI dos dois pickers sem a 4ª cópia do listbox (barato de reverter, mas forma)
|
||||
|
||||
**Opções:** A) um componente de domínio `ContentPiecePeopleField` (chips + painel inline) sobre os primitivos `Command`/`CommandInput`/`CommandList`/`CommandItem` (`src/components/ui/Command.tsx`) com dois usos — liderança async e figura local+texto livre; B) 4ª cópia do listbox inline de `StateDeputySelect`; C) `CommandDialog` modal (`ResponsibleMultiSelect`); D) Base UI `Combobox` flutuante (`FormCombobox`).
|
||||
**Recomendação:** **A** — `MunicipalityRelationEditor.tsx:397-440` já usa `Command` **inline** (precedente direto) e o cmdk entrega teclado/a11y sem nova cópia; o componente é de domínio (2 usos no mesmo form), sem generalizar o `StateDeputySelect` do Studio S30 (que declara o gatilho de revisita "a fourth consumer", que assim não dispara).
|
||||
**Rejeitadas:** **B** (quarta cópia manual = o gatilho de revisita declarado no próprio arquivo, com custo de a11y); **C** (modal ≠ cenas 01/02, que desenham painel inline); **D** (popup flutuante ≠ painel inline do artefato).
|
||||
|
||||
### D7 — Verificação por camada (barato de reverter, mas explícito)
|
||||
|
||||
**Opções:** A) unit de componente do picker (testing-library, search stub) + int do action/loader/contrato + e2e público browser (`frontendConteudos`) + render browserless da ficha (`campaignSpeechAcervo`); B) e2e de browser novo só para o picker; C) só int/unit (sem e2e público novo).
|
||||
**Recomendação:** **A** — a fiação do client (chips, hidden inputs, debounce, remoção) é barata no unit de componente (há precedente `activityOverlay.unit.spec.tsx`); o action + snapshot + gate + fail-closed são domínio (int); o journey "marcado → filtro público" é o contrato do S27 e roda no `frontendConteudos` (seeding REST admin com `contact`+`leadership`+`leaders`, o caminho real do hook). Nenhum project/spec novo (sem custo de manifest).
|
||||
**Rejeitadas:** **B** porque duplica setup de login/browser por cobertura que o unit de componente + int já dão (gatilho: quebra real de fiação no browser); **C** porque o contrato público novo (faceta + nome no card) precisa de prova HTTP real.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Schema / dados**
|
||||
|
||||
- **`src/collections/ContentPiece.ts`** (editar): campos `leaders` (relationship → `leadership`, hasMany, label "Lideranças da campanha", `maxRows: CONTENT_PIECE_LEADERS_MAX`), `leaderNames` (text hasMany, readOnly — "Nomes para a Central pública"; snapshot), `publicFigures` (text hasMany, label "Figuras públicas", `maxRows` e `maxLength` do dono); `deriveContentPieceCatalogIndex` ganha a derivação condicional de `leaderNames` (relation touched, ordem dos ids, nomes em falta pulados), a normalização de `publicFigures` e os dois campos em `contentPieceSearchText`.
|
||||
- **`src/lib/contentPiece.ts`** (editar): `CONTENT_PIECE_LEADERS_MAX`, `CONTENT_PIECE_PUBLIC_FIGURES_MAX`, `CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH`, `CONTENT_PIECE_LEADER_SEARCH_LIMIT`; `ContentPieceSearchInput` + `leaderNames`/`publicFigures`.
|
||||
- **`src/lib/publicFigureCatalog.ts`** (novo, puro/client-safe): catálogo derivado do `stateDeputyCatalog` + `PUBLIC_FIGURE_PERSONALITIES`, `filterPublicFigures`, `resolvePublicFigureName`, `normalizeContentPiecePublicFigures`, `isPublicFigureCatalogName`.
|
||||
- **Migration:** `pnpm migrate:create add_content_piece_people` → `20260924_<HHMMSS>_add_content_piece_people`, aditiva: tabela `content_piece_rels` (relationship hasMany `leaders`), `content_piece_leader_names` e `content_piece_public_figures` (text hasMany). **Sem `ALTER TYPE`** (D5) e **sem backfill** — peças antigas ficam sem pessoas até a ficha editar. `pnpm migrate` local + `pnpm generate:types` (regerar `src/payload-types.ts`); nunca editar migration existente.
|
||||
|
||||
**Access / busca interna**
|
||||
|
||||
- **`src/utilities/content/contentPieceLeaderOptions.ts`** (novo, `server-only`): D1 — gate `canReadCommunicationCatalog`, `isContactSearchQueryReady`, teto 20, projeção `{id, label}`, bypass intencional comentado (só nome de exibição).
|
||||
- **`src/app/(campaign)/campanha/actions/contentPieces.ts`** (editar): `searchContentPieceLeaderOptionsForActor(query)` (server action; gate + erro `CONTENT_PIECE_FORBIDDEN_MESSAGE`); `updateContentPieceForActor` aceita `leaderIds`/`publicFigures`, canonicaliza figuras e persiste `leaders`/`publicFigures` (sem novos `curatedFields`).
|
||||
- **`src/lib/schemas/contentPiece.ts`** (editar): `leaderIds` (ints, teto) e `publicFigures` (strings, teto/tamanho) em `contentPieceUpdateRequestSchema`.
|
||||
- **`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/formActions.ts`** (editar): lê `getAll('leaderIds')` (ints válidos) e `repeatedFormTexts(formData, 'publicFigures')`.
|
||||
- **`src/utilities/content/contentPiecePageData.ts`** (editar): `contentPieceDetailSelect` + `leaders`/`publicFigures`; o detalhe resolve `leaderOptions` via `resolveContentPieceLeaderOptions` e devolve `publicFigures`.
|
||||
|
||||
**Contrato público (S27)**
|
||||
|
||||
- **`src/lib/contentPieceCatalog.ts`** (editar): `'lideranca'` em `CONTENT_PIECE_CATALOG_FACETS` (append, ordem Tipo·Cidade·Região·Tema·Instituição·Lideranças) + label "Lideranças"; `lideranca` em params/parse (`slugFacet`)/href; `contentPiecePublicPeople`; `contentPieceCatalogFacets.lideranca`; filtro AND; `ContentPiecePublicSource`/`ContentPiecePublicItem` com `leaderNames`/`publicFigures`; `metaLabel` com o primeiro nome.
|
||||
- **`src/utilities/content/contentPieceReads.ts`** (editar): `select` público ganha `leaderNames: true, publicFigures: true` — **jamais `leaders`**.
|
||||
- **`src/components/conteudos/ContentPieceFilters.tsx`** (editar): caption "Em peças publicadas" no dropdown da faceta `lideranca` (design cena 03); placeholder "Encontre uma peça por assunto, cidade ou pessoa…". O chip/hidden input/remoção são automáticos via `CONTENT_PIECE_CATALOG_FACETS` e `ContentPieceCatalogFacets` (Record obriga a chave nova em tempo de compilação).
|
||||
- **Card público:** sem mudança de arquivo — `CardMeta` (`ContentPieceCard.tsx:96-97`) já renderiza `metaLabel`.
|
||||
|
||||
**UI interna (Impeccable C — port classe-a-classe)**
|
||||
|
||||
- **`src/components/campaign/content/ContentPiecePeopleField.tsx`** (novo, client): bloco "Quem aparece na peça" (heading, ajuda "Marque somente pessoas visíveis…", badge "Curadoria humana", empty state "Ninguém marcado…"), dois pickers (liderança: busca async via prop `searchLeaders`; figura: catálogo local + "Usar «texto»"), chips removíveis e hidden inputs repetidos `leaderIds`/`publicFigures`; reusa `Command` inline + `Badge`.
|
||||
- **`src/components/campaign/content/ContentPieceForm.tsx`** (editar): props `leaderOptions`, `publicFigures`, `searchLeaders`; render do fieldset `sm:col-span-2`.
|
||||
- **`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx`** (editar): passa os novos props (o `searchLeaders` é a server action, padrão já usado com `formAction`).
|
||||
- **`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx`**: sem mudança (Q(a) da intenção: sem filtro/coluna interno no v1).
|
||||
- **Changelog:** `docs/changelog/2026-09-24-s37.md` (entrada curta; nunca editar o agregado/HISTORY).
|
||||
|
||||
**Access / Consent:** um bypass intencional documentado (D1) e mais nada; nenhum `Consent` novo, nenhum cadastro de pessoa, `leader` intocado.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — a faceta é lista de nomes, sem contagem, série, ranking ou score (declarado na intenção); os nomes no card são metadado de conteúdo, não apresentação de dado.
|
||||
|
||||
## Design tier
|
||||
|
||||
Artefato aprovado no gate: `docs/plans/central-conteudos-liderancas-ui-design.html`. Superfícies cobertas e como o port as atende:
|
||||
|
||||
- **Cena 01 — ficha interna desktop 1280:** bloco "Quem aparece na peça" com os dois recortes lado a lado, chips, painéis inline (liderança e figura), badge "Curadoria humana" e as duas linhas de ajuda; `ContentPiecePeopleField` porta classe-a-classe.
|
||||
- **Cena 02 — ficha mobile 390:** empilhamento dos dois recortes e o estado "Ninguém marcado. A peça pode ser publicada normalmente…" (o form já é responsivo; o empty state é copy do artefato).
|
||||
- **Cena 03 — catálogo público desktop/mobile:** chip `Lideranças` na fileira, dropdown com a caption "EM PEÇAS PUBLICADAS", chip ativo "Lideranças · Nome ×", card com o nome na linha de metadados e o placeholder "assunto, cidade ou pessoa…"; o box tracejado "Regra visível do gate" é lido como anotação do gate (a regra vira a caption do dropdown), não como UI.
|
||||
- **Cena 04/estado de peça sem ninguém marcado:** coberto pelo empty state da cena 02.
|
||||
|
||||
**Não cobertas → trigger (a) (dispatch do designer antes do markup final, se aplicável):**
|
||||
|
||||
1. **Posição do bloco no formulário completo do C211** — o artefato mostra um close-up (Título → bloco → Instituição/Cidade) e omite Descrição/Tipo/Data/Temas/Transcrição; o port insere o fieldset `sm:col-span-2` **depois de Temas, antes de Cidade** e o designer valida no gate.
|
||||
2. **Picker no mobile do desktop** — o artefato cobre o painel inline em 390; o componente mantém o mesmo painel em qualquer largura (sem cena extra) — designer valida.
|
||||
3. **Linha de "quem aparece" na página individual da peça** — não desenhada; fica fora do v1 e entra como cena nova se o gate quiser.
|
||||
4. **Placeholder mobile "Busque uma pessoa…"** — lido como mock; vale o texto do desktop ("assunto, cidade ou pessoa").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server — quota ~45%:** constants + campos + hook (snapshot e `searchText`) + migration + types; `publicFigureCatalog`; `contentPieceLeaderOptions` + server action; contrato público (`lideranca` em facets/parse/href/filter/VM/metaLabel) + select do read público; unit + int do caminho (ficha marca → publica → `/conteudos?lideranca=` acha; despublica → some) verdes antes de seguir.
|
||||
2. **UI (port classe-a-classe) — quota ~35%:** `ContentPiecePeopleField` + form + ficha page + caption/placeholder dos filtros; unit de componente do picker.
|
||||
3. **Gates — quota ~20%:** `pnpm gate:fast` (lint/format/typecheck/knip/cycles/unit); `pnpm test:int`; e2e `frontendConteudos` (curado, roda pelo PR high-risk) + `campaignSpeechAcervo` local (`pnpm test:e2e --no-deps -- tests/e2e/campaignSpeechAcervo.e2e.spec.ts --workers=1`); `pnpm push` → PR `--base main` (a verificação viva é o e2e de rotas reais).
|
||||
|
||||
## Testes previstos
|
||||
|
||||
- **`tests/unit/contentPieceCatalog.unit.spec.ts`** (editar): parse de `lideranca` (slug válido/ inválido/desconhecido → null); href canônico com a faceta na ordem fixa; `contentPieceCatalogFacets.lideranca` = união líderes+figuras, dedup por `slugify`, só de itens publicados, homônimo colapsa; filtro `lideranca` combinado com `tipo`/`q` (AND); VM com `leaderNames`/`publicFigures` e `metaLabel` liderando pelo primeiro nome (e inalterado sem pessoas).
|
||||
- **`tests/unit/contentPiece.unit.spec.ts`** (editar): `contentPieceSearchText` inclui e normaliza nomes de liderança e figuras (acento/caixa); limites novos.
|
||||
- **`tests/unit/publicFigureCatalog.unit.spec.ts`** (novo): catálogo deriva as 53 dobradinhas do `stateDeputyCatalog` (contagem/nomes/`slug` prefixado); `resolvePublicFigureName` accent/pontuação-insensível, ambíguo → null, desconhecido → null; `normalizeContentPiecePublicFigures` dedupe/canonicalização/teto; `filterPublicFigures`.
|
||||
- **`tests/unit/contentPiecePeopleField.unit.spec.tsx`** (novo, componente com testing-library): busca stub devolve opções, seleção vira chip + hidden input; remoção limpa; texto livre vira figura; estado vazio; limite.
|
||||
- **`tests/int/contentPiece.int.spec.ts`** (editar; fixtures já criam `contact`/`leadership`): action de busca — communicator recebe `{id,label}` e **nada além** (shape assertado), advisor/leader negados; save da ficha persiste `leaders`, snapshot `leaderNames` na ordem e figuras canonicalizadas; limpar funciona; leitura pública deriva faceta/nome, rascunho/despublicado nunca aparece, `q` acha pelo nome; `getPublishedContentPieceRecords` não traz `leaders`/`contact`; job (`runContentPieceJob` com stub) não toca os campos novos.
|
||||
- **`tests/e2e/frontendConteudos.e2e.spec.ts`** (editar): seeding REST admin de `contact` + `leadership` + peça com `leaders` e `publicFigures`; `/conteudos` mostra o chip `Lideranças`, filtra por `?lideranca=<slug>` e o card mostra o nome; despublicar a peça remove o nome da faceta; cleanup de contatos/lideranças no `afterAll`.
|
||||
- **`tests/e2e/campaignSpeechAcervo.e2e.spec.ts`** (editar, seção C211): a ficha renderiza o bloco "Quem aparece na peça" com o chip da liderança selecionada para o communicator; advisor segue negado.
|
||||
- **Fixtures:** `tests/helpers/campaignFixtures.ts` **intocado** (contact/leadership já na union e no cleanup).
|
||||
|
||||
## Pins a atualizar (valores exatos)
|
||||
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs:159-179`** (entrada S27): acrescentar `'src/lib/publicFigureCatalog.ts'` → `['frontendConteudos']`.
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs:440-464`** (vertical de comunicação/C211): acrescentar `'src/lib/publicFigureCatalog.ts'` (o picker e o int da Central usam o catálogo).
|
||||
- **`tests/unit/e2eAffectedManifest.unit.spec.ts:54-79`** — **intocado**: `frontendConteudos` já está no curado; nenhum risk prefix novo.
|
||||
- **`tests/unit/codebaseConventions.unit.spec.ts:202-237`** e **`:409-556`** — **intocado**: sem rota POST nova (a busca é server action) e nenhum módulo novo no top-level de `src/utilities/` (tudo em `src/utilities/content/`).
|
||||
- **`tests/unit/contentPieceCatalog.unit.spec.ts`**, **`tests/unit/contentPiece.unit.spec.ts`**, **`tests/int/contentPiece.int.spec.ts`** e os dois e2e — estendidos conforme "Testes previstos".
|
||||
- **`src/payload-types.ts`** — regenerado (`pnpm generate:types`), nunca editado à mão.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Sugerir nomes automaticamente no job** (match de transcript no catálogo) — menção ≠ aparição; catálogo intocado (D5); gatilho: pedido explícito da comunicação, com item próprio.
|
||||
- **Filtro/coluna na lista interna de peças** — só se o gate pedir (Q(a) da intenção); hoje a lista fica como está.
|
||||
- **Relação com `stateDeputy`, `Speech.mentionedPeople`/C200, acervo** — superfícies distintas; não copiar o par nem acoplar.
|
||||
- **Página pública por pessoa, diretório, contagem/placar de aparições, analytics** — vedados pela intenção.
|
||||
- **Mostrar nomes na página individual da peça** — sem cena; trigger (a) se o gate quiser.
|
||||
- **Tocar `src/utilities/access/*`/ampliar `canReadLeadership`** — rejeitado em D1; o bypass vive no domínio de conteúdo, documentado.
|
||||
- **Tocar `StateDeputySelect`/Studio S30** — fora; o componente novo é de domínio.
|
||||
- **Backfill de peças existentes** — sem dados; a faceta nasce vazia e cresce com a curadoria.
|
||||
- **`Consent`/`Contact`/collection novos** — proibidos; nenhuma PII nova é capturada.
|
||||
- **e2e de browser novo para o picker** — unit de componente + int + e2e público cobrem (D7); gatilho: falha de fiação real.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Bypass de nomes para o `communicator`** (novo caminho de leitura): gate `canReadCommunicationCatalog` + projeção fixa `{id, label}` + teto e mínimo de 2 chars; unit/int assertam o shape e o negado; comentário de bypass explícito no módulo; se um campo a mais vazar no retorno, o teste quebra.
|
||||
- **Snapshot `leaderNames` defasado** (contato renomeado fora da ficha): o form envia `leaderIds` em todo save, então o hook recomputa no toque; o job/publish não limpam; gatilho: se renomes virarem frequentes, recomputar também na entrada em `publicado`.
|
||||
- **`searchText` dessincronizado em update parcial**: hook com fallback `originalDoc` (padrão `deriveSpeakerNames`); int cobre publish que envia só `status`.
|
||||
- **`leaders`/`Contact` vazando no read público**: `select` explícito nunca inclui a relação; teste int asserta que o record cru não tem `leaders` e que o VM não carrega ids/contato; a faceta lê só o snapshot.
|
||||
- **Faceta com homônimos/variações**: colapso por `slugify` documentado + unit; a faceta é filtro por nome, não identidade.
|
||||
- **Migration aditiva em collection publicada**: só tabelas novas, sem backfill; o deploy já aplica `payload migrate` antes do build; revisar o SQL gerado e rodar `pnpm migrate` + int local antes do push.
|
||||
- **PR high-risk (migration)**: o CI seleciona o curado (`frontendConteudos`); `campaignSpeechAcervo` roda no full do `verify` do deploy e localmente com o comando da Fase 3.
|
||||
- **e2e de pessoas com cleanup**: contatos/lideranças criados no spec entram nas listas de limpeza na ordem FK (peça → liderança → contato).
|
||||
|
||||
## Hard-stops (aprovação humana explícita — valem mesmo em `--auto`)
|
||||
|
||||
1. **Schema/migration (Fase 1):** `pnpm migrate:create add_content_piece_people` + `pnpm migrate` só no banco local do worktree (`teqo_wt*`/`teqo_wt*_test`) — aditiva, sem backfill, sem `ALTER TYPE`. `--auto` aprova o plano, **não** o schema. **Aprovada na sessão `--auto` (2026-09-24)** — criar/aplicar só no banco local do worktree; nunca apontar para banco remoto fora do deploy.
|
||||
2. **Contrato público (Fase 1):** faceta nova `lideranca` no param de `/conteudos` (aditiva, append na ordem fixa) + os campos `leaderNames`/`publicFigures` no shape público selecionado e no `metaLabel` do card. **Aprovado na sessão `--auto` (2026-09-24).**
|
||||
3. **Bypass intencional de Local API (D1):** leitura de nomes de liderança para o `communicator` com projeção `{id, nome}` — decisão de segurança mais sensível do item (self-score abaixo). **Aprovado na sessão `--auto` (2026-09-24)**, com gate + projeção fixa + teto + testes de shape.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: ficha registra os dois recortes (liderança existente + figura pública em texto com catálogo); UMA faceta `Lideranças` une os dois; nomes na busca por termo e no card; fail-closed (rascunho/despublicado nunca expõe nome); curadoria manda.
|
||||
- [ ] Guardrails: sem dado interno de liderança na superfície pública; sem cadastro paralelo; sem `Consent` novo; leader lockdown e `estimatedVotes` intocados; `consent`/LGPD intocados.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: bypass de Local API **único e documentado** (D1) com projeção mínima; escrita multi-campo na action existente (sem transação nova — um único `payload.update`); identificadores em inglês/copy pt-BR; migrations existentes intocadas; sem `push`.
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access e write paths mudam; pins de manifest atualizados; e2e público estendido.
|
||||
- [ ] `pnpm gate:fast` verde; `pnpm test:int` do spec verde; e2e `frontendConteudos` verde; `campaignSpeechAcervo` rodado local verde; `pnpm push` via GitHub.
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
- **D1 — Liderança + access:** relationship hasMany `leaders` + snapshot `leaderNames` + loader gated com bypass intencional, projeção `{id, nome}`, teto 20 e mín. 2 chars. Rejeitadas: texto sem vínculo (B), picker só para coordinator/candidate (C) e ampliar `canReadLeadership` (D).
|
||||
- **D2 — Figuras públicas:** `text hasMany` + catálogo derivado do `stateDeputyCatalog` + personalidades por data edit + canonicalização de grafia no save. Rejeitadas: single text (B), relação com `stateDeputy` (C), texto sem catálogo (D).
|
||||
- **D3 — Faceta:** uma `lideranca` derivada em memória da união dos nomes dos itens publicados, chave `slugify`, homônimos colapsam; read público nunca toca `leadership`/`Contact`. Rejeitadas: duas facetas (B) e chave por id/slug de origem (C).
|
||||
- **D4 — Busca/card:** nomes no `searchText` (type + call site) e `metaLabel` liderando com o primeiro nome; página da peça fora do v1. Rejeitadas: fora da busca (B) e linha nova sem design (C).
|
||||
- **D5 — Curadoria:** `content_piece_curated_fields` **intocado**; sem sugeridor automático (D2/A e C rejeitadas); gatilho registrado.
|
||||
- **D6 — UI:** painel inline sobre `Command` (precedente `MunicipalityRelationEditor`), sem a 4ª cópia do listbox e sem tocar o Studio S30. Rejeitadas: cópia manual (B), modal (C), popup flutuante (D).
|
||||
- **D7 — Verificação:** unit de componente + int + e2e público + render browserless da ficha; sem spec novo. Rejeitadas: e2e de browser do picker (B) e só unit/int (C).
|
||||
- **Sem Consent, sem collection nova, sem `ALTER TYPE`, sem rota nova** — registrado, não presumido.
|
||||
|
||||
## Self-score
|
||||
|
||||
**Self-score decision-quality: 4/5.** (1) Todas as decisões caras (referência + access, storage/catálogo de figuras, faceta única sem PII, busca/card, colisão com `curatedFields`) têm Opções + Recomendação + Rejeitadas explícitas; (2) cabe no appetite herdado: sem collection/rota nova, uma migration aditiva, reusa `Command` inline, o hook de denormalização, o contrato público do S27, `normalizeForSearch`/`slugify`, `stateDeputyCatalog` e o padrão de server action como prop; (3) rabbit holes nomeados (sugestão automática, filtro interno, `stateDeputy`/C200, ranking, página por pessoa, ampliar RBAC, backfill, browser e2e); (4) depth check: donos existentes editados (contrato público, collection/hook, action, form) e só nasce módulo onde não há dono (`publicFigureCatalog`, `contentPieceLeaderOptions`, `ContentPiecePeopleField`); (5) outcome preservado — fail-closed, uma faceta, curadoria manual, sem PII e sem reescrever o aceite. O que impede o 5 é o **bypass intencional de leitura de nomes de liderança** para o `communicator` (D1): é a decisão de segurança mais sensível do item e pede confirmação humana explícita no gate, mesmo com projeção mínima, gate e testes de shape.
|
||||
|
||||
## Simplify — défers deste triage (2026-09-24)
|
||||
|
||||
Duas revisões paralelas (estrutural + qualidade) rodaram sobre o diff; os achados aplicáveis foram corrigidos na sessão (null-safety do hook, `select` honesto, ordem de submissão preservada nos chips, `slugify`/`normalizeSearchPhrase` do catálogo, `PeopleChip`/`orderOptionsById` extraídos, `leaderIds` removido do page data, gate num só lugar). Ficaram deferidos, com gatilho:
|
||||
|
||||
- **Defer (estados async dos pickers).** O par loading/failed de `ContentPiecePeopleField` repete o de `ResponsibleMultiSelect`/`AsyncSearchCombobox` (3º consumidor). Gatilho: um 4º consumidor dos estados assíncronos ou o próximo item que mexer em um dos três → extrair um shell único (`shared/AsyncSearchStates`), sem tocar nos fluxos aprovados.
|
||||
- **Defer (projeção de liderança).** `contentPieceLeaderOptions` repete a projeção/label de `activityLeadershipOptions` (que usa `overrideAccess: false` — semântica de access diferente da deste item). Gatilho: um 3º consumidor do nome de exibição de liderança ou o próximo item que alterar o label (ex.: C200/speakers) → extrair a projeção pura compartilhada.
|
||||
- **Descartado:** `facet === 'lideranca'` repetido no componente de filtros (leitura local, 3 pontos); `select: { contact: true }` (o plano D1 o escolheu; o tipo do Payload não aceita select aninhado e o `{ id, label }` + int test são a fronteira do shape); `PUBLIC_FIGURE_PERSONALITIES`/`kind` vazios (seam aprovado no plano, v1 deliberadamente vazio).
|
||||
|
||||
## Fechamento (sessão `--auto`, 2026-09-24)
|
||||
|
||||
- **Design (trigger c): certificado PARIDADE** pelo `designer` (tier primário, sem `DEGRADED`) após 3 passadas contra o app renderizado (390/1280): ficha com/sem pessoas (cenas 01/01A/02) e catálogo com a faceta aberta/filtrada/card (cena 03).
|
||||
- **Hard-stops:** aprovados explicitamente pelo humano na sessão (migration aditiva só no banco local, faceta/contrato público aditivo e o bypass D1 com projeção `{id, nome}` + testes de shape).
|
||||
|
|
@ -19,6 +19,22 @@
|
|||
--line: rgb(0 0 0/0.12);
|
||||
--display: 'Exo 2', 'Arial Black', sans-serif;
|
||||
}
|
||||
[data-theme='campaign'] {
|
||||
--radius: 0.5rem;
|
||||
--background: #ffffff;
|
||||
--foreground: #1c1917;
|
||||
--primary: #c51414;
|
||||
--primary-foreground: #ffffff;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #6b7280;
|
||||
--border: rgb(28 25 23/0.12);
|
||||
--field-background: #ffffff;
|
||||
--field-foreground: #1c1917;
|
||||
--field-placeholder: rgb(28 25 23/0.5);
|
||||
--field-border: #d6d3d1;
|
||||
--scope: #fff5ed;
|
||||
--scope-foreground: #6b2d15;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
@ -230,6 +246,9 @@
|
|||
>
|
||||
</div>
|
||||
<div class="px-3 py-2 text-sm">Personalidade Exemplo</div>
|
||||
<div class="border-t px-3 py-2 text-sm font-medium text-[#c51414]">
|
||||
Usar «Nome digitado»
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -251,10 +270,212 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
|
||||
<span>CENA 01A</span>
|
||||
<h2>Ficha interna · desktop 1280 · posição no formulário completo</h2>
|
||||
</div>
|
||||
<div
|
||||
class="mx-auto mb-3 flex max-w-3xl items-center gap-3 rounded-lg border border-[#184e92]/25 bg-[#eef4fb] px-4 py-3 text-xs leading-5 text-[#184e92]"
|
||||
>
|
||||
<span
|
||||
class="grid size-6 shrink-0 place-items-center rounded-full bg-[#184e92] font-black text-white"
|
||||
>5</span
|
||||
>
|
||||
<p>
|
||||
<b>Posição aprovada:</b> depois de <b>Temas</b> e antes de <b>Cidade</b>. O bloco ocupa
|
||||
as duas colunas do formulário.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="desktop frame mx-auto overflow-hidden rounded-xl bg-white"
|
||||
data-theme="campaign"
|
||||
>
|
||||
<header class="flex h-14 items-center justify-between border-b bg-[#fafaf9] px-6">
|
||||
<b>Campanha · Comunicação</b>
|
||||
<span class="text-sm text-[#6b7280]">Editar peça</span>
|
||||
</header>
|
||||
<div class="p-8">
|
||||
<a class="text-xs font-semibold text-[#c51414]">← Conteúdos</a>
|
||||
<h2 class="mt-2 text-xl font-semibold">Título da peça em revisão</h2>
|
||||
<p class="mt-1 text-sm text-[#6b7280]">Revise os dados antes de publicar.</p>
|
||||
|
||||
<form class="mt-7 grid max-w-3xl grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="flex w-full flex-col gap-2 sm:col-span-2">
|
||||
<p class="label">Título</p>
|
||||
<div
|
||||
class="flex min-h-11 items-center rounded-lg border border-[#d6d3d1] bg-white px-4 text-sm"
|
||||
>
|
||||
Título da peça em revisão
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 sm:col-span-2">
|
||||
<p class="label">Descrição</p>
|
||||
<div
|
||||
class="min-h-24 rounded-xl border border-[#d6d3d1] bg-white px-4 py-2 text-sm text-[#6b7280]"
|
||||
>
|
||||
Resumo curto da peça para o catálogo público.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<p class="label">Tipo</p>
|
||||
<div
|
||||
class="flex min-h-11 items-center justify-between rounded-lg border border-[#d6d3d1] bg-white px-4 text-sm"
|
||||
>
|
||||
Vídeo <span class="text-[#6b7280]">⌄</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<p class="label">Data da peça</p>
|
||||
<div
|
||||
class="flex min-h-11 items-center justify-between rounded-lg border border-[#d6d3d1] bg-white px-4 text-sm"
|
||||
>
|
||||
24/09/2026 <span class="text-[#6b7280]">▣</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 sm:col-span-2">
|
||||
<p class="label">Temas</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class="flex min-h-9 items-center rounded-full border border-black/25 bg-[#f4f4f5] px-3 text-xs font-semibold"
|
||||
>Saúde</span
|
||||
>
|
||||
<span
|
||||
class="flex min-h-9 items-center rounded-full border border-black/10 px-3 text-xs text-[#6b7280]"
|
||||
>Educação</span
|
||||
>
|
||||
<span
|
||||
class="flex min-h-9 items-center rounded-full border border-black/10 px-3 text-xs text-[#6b7280]"
|
||||
>Direitos</span
|
||||
>
|
||||
<span
|
||||
class="flex min-h-9 items-center rounded-full border border-black/10 px-3 text-xs text-[#6b7280]"
|
||||
>Trabalho</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
class="flex flex-col gap-4 rounded-xl border border-black/12 p-5 sm:col-span-2"
|
||||
>
|
||||
<legend class="sr-only">Quem aparece na peça</legend>
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold">Quem aparece na peça</h3>
|
||||
<p class="mt-1 text-xs leading-5 text-[#6b7280]">
|
||||
Marque somente pessoas visíveis ou participantes desta peça. A publicação
|
||||
expõe apenas o nome.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-[#fff5ed] px-2 py-1 text-[11px] font-semibold text-[#6b2d15]"
|
||||
>
|
||||
Curadoria humana
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="label">Lideranças da campanha</p>
|
||||
<p class="mt-1 text-xs leading-5 text-[#6b7280]">
|
||||
Busca nos registros existentes. Nenhum telefone, município ou voto vai para a
|
||||
Central pública.
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span class="tag">Nome da liderança <b>×</b></span>
|
||||
</div>
|
||||
<div class="mt-2 rounded-xl border bg-white p-1 shadow-lg">
|
||||
<div
|
||||
class="flex h-10 items-center gap-2 rounded-lg bg-[#f4f4f5] px-3 text-sm text-[#6b7280]"
|
||||
>
|
||||
<span>⌕</span><span>Buscar liderança…</span>
|
||||
</div>
|
||||
<div class="mt-1 rounded-sm bg-[#f4f4f5] px-2 py-2 text-sm">
|
||||
<b>Nome da liderança</b>
|
||||
<span class="block text-xs font-normal text-[#6b7280]"
|
||||
>Registro da campanha</span
|
||||
>
|
||||
</div>
|
||||
<div class="px-2 py-2 text-sm">Outra liderança de exemplo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="label">Figuras públicas</p>
|
||||
<p class="mt-1 text-xs leading-5 text-[#6b7280]">
|
||||
Texto curado com catálogo, no mesmo molde de Instituição.
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span class="tag">Dep. Estadual Exemplo <b>×</b></span>
|
||||
</div>
|
||||
<div class="mt-2 rounded-xl border bg-white p-1 shadow-lg">
|
||||
<div
|
||||
class="flex h-10 items-center gap-2 rounded-lg bg-[#f4f4f5] px-3 text-sm text-[#6b7280]"
|
||||
>
|
||||
<span>⌕</span><span>Digite ou escolha uma figura pública…</span>
|
||||
</div>
|
||||
<div class="mt-1 rounded-sm bg-[#f4f4f5] px-2 py-2 text-sm">
|
||||
<b>Dep. Estadual Exemplo</b>
|
||||
<span class="block text-xs font-normal text-[#6b7280]"
|
||||
>Figura pública catalogada</span
|
||||
>
|
||||
</div>
|
||||
<div class="px-2 py-2 text-sm">Personalidade Exemplo</div>
|
||||
<div class="border-t px-2 py-2 text-sm font-medium text-[#c51414]">
|
||||
Usar «Nome digitado»
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<p class="label">Cidade</p>
|
||||
<div
|
||||
class="flex min-h-11 items-center justify-between rounded-lg border border-[#d6d3d1] bg-white px-4 text-sm text-[#6b7280]"
|
||||
>
|
||||
Sem cidade <span>⌄</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<p class="label">Instituição</p>
|
||||
<div
|
||||
class="flex min-h-11 items-center rounded-lg border border-[#d6d3d1] bg-white px-4 text-sm text-[#6b7280]"
|
||||
>
|
||||
Nome da instituição
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 sm:col-span-2">
|
||||
<p class="label">Transcrição / texto</p>
|
||||
<div
|
||||
class="min-h-44 rounded-xl border border-[#d6d3d1] bg-white px-4 py-2 text-sm leading-6 text-[#6b7280]"
|
||||
>
|
||||
Transcrição ou texto completo da peça…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<div class="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-800">
|
||||
Alterações salvas.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn primary min-h-11 sm:col-span-2 sm:justify-self-end">
|
||||
Salvar alterações
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="scene mb-3 flex items-center gap-3 text-xs font-bold text-[#6c615e]">
|
||||
<span>CENA 02</span>
|
||||
<h2>Ficha interna · mobile 390 · pessoa marcada e ninguém marcado</h2>
|
||||
<h2>Ficha interna · mobile 390 · painéis inline, pessoa marcada e ninguém marcado</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-8">
|
||||
<div class="phone frame min-h-[760px] overflow-hidden bg-white">
|
||||
|
|
@ -276,12 +497,33 @@
|
|||
<div class="field mt-2 flex justify-between text-[#6b7280]">
|
||||
<span>Buscar liderança…</span><span>⌄</span>
|
||||
</div>
|
||||
<div class="mt-1 rounded-xl border bg-white p-1 shadow-lg">
|
||||
<div class="rounded-lg bg-[#f4f4f5] px-3 py-2 text-sm">
|
||||
<b>Nome da liderança</b>
|
||||
<span class="block text-xs font-normal text-[#6b7280]"
|
||||
>Registro da campanha</span
|
||||
>
|
||||
</div>
|
||||
<div class="px-3 py-2 text-sm">Outra liderança de exemplo</div>
|
||||
</div>
|
||||
<p class="label mt-5">Figuras públicas</p>
|
||||
<div class="mt-2 flex">
|
||||
<span class="tag">Dep. Estadual Exemplo <b>×</b></span>
|
||||
</div>
|
||||
<div class="field mt-2 flex justify-between text-[#6b7280]">
|
||||
<span>Digite ou escolha…</span><span>⌄</span>
|
||||
<span>Digite ou escolha uma figura pública…</span><span>⌄</span>
|
||||
</div>
|
||||
<div class="mt-1 rounded-xl border bg-white p-1 shadow-lg">
|
||||
<div class="rounded-lg bg-[#f4f4f5] px-3 py-2 text-sm">
|
||||
<b>Dep. Estadual Exemplo</b>
|
||||
<span class="block text-xs font-normal text-[#6b7280]"
|
||||
>Figura pública catalogada</span
|
||||
>
|
||||
</div>
|
||||
<div class="px-3 py-2 text-sm">Personalidade Exemplo</div>
|
||||
<div class="border-t px-3 py-2 text-sm font-medium text-[#c51414]">
|
||||
Usar «Nome digitado»
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary mt-5 w-full">Salvar alterações</button>
|
||||
|
|
@ -303,7 +545,7 @@
|
|||
<p class="label mt-5">Lideranças da campanha</p>
|
||||
<div class="field mt-2 text-[#6b7280]">Buscar liderança…</div>
|
||||
<p class="label mt-5">Figuras públicas</p>
|
||||
<div class="field mt-2 text-[#6b7280]">Digite ou escolha…</div>
|
||||
<div class="field mt-2 text-[#6b7280]">Digite ou escolha uma figura pública…</div>
|
||||
</div>
|
||||
<button class="btn primary mt-5 w-full">Salvar alterações</button>
|
||||
</div>
|
||||
|
|
@ -387,7 +629,7 @@
|
|||
</header>
|
||||
<div class="p-4">
|
||||
<div class="h-11 rounded-[10px] border px-3 py-3 text-sm text-[#6c615e]">
|
||||
Busque uma pessoa…
|
||||
Encontre uma peça por assunto, cidade ou pessoa…
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2 overflow-hidden">
|
||||
<span class="chip shrink-0">Tipo⌄</span
|
||||
|
|
|
|||
|
|
@ -172,6 +172,8 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/lib/contentPieceCatalog',
|
||||
'src/lib/contentPieceHomeSelection.ts',
|
||||
'src/lib/contentPieceShare',
|
||||
// S37 — the catalog behind the public "Lideranças" names.
|
||||
'src/lib/publicFigureCatalog.ts',
|
||||
'src/lib/contentEvents.ts',
|
||||
'src/lib/cardModels.ts',
|
||||
'src/lib/schemas/contentEvent.ts',
|
||||
|
|
@ -495,6 +497,8 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/lib/reel',
|
||||
'src/lib/recording',
|
||||
'src/lib/contentPiece',
|
||||
// S37 — the public-figure catalog of the ficha picker.
|
||||
'src/lib/publicFigureCatalog.ts',
|
||||
'src/lib/privateMedia',
|
||||
// The role predicates drive the vertical gate and the assistant surfaces.
|
||||
'src/lib/campaignRoles',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Link from 'next/link'
|
|||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { searchContentPieceLeaderOptionsForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
|
||||
import { ContentPieceAttachFileButton } from '@/components/campaign/content/ContentPieceAttachFileButton'
|
||||
import { ContentPieceCirculationPanel } from '@/components/campaign/content/ContentPieceCirculationPanel'
|
||||
import { ContentPieceForm } from '@/components/campaign/content/ContentPieceForm'
|
||||
|
|
@ -299,6 +300,9 @@ export default async function ContentPieceDetailPage({ params }: ContentPieceDet
|
|||
transcript: piece.transcript,
|
||||
}}
|
||||
municipalityOptions={options.municipalities}
|
||||
leaderOptions={data.leaderOptions}
|
||||
publicFigures={piece.publicFigures}
|
||||
searchLeaders={searchContentPieceLeaderOptionsForActor}
|
||||
formAction={updateContentPieceFormAction}
|
||||
/>
|
||||
{piece.fileHref ? (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { isContentPieceType } from '@/lib/contentPiece'
|
|||
import {
|
||||
nullableFormText,
|
||||
repeatedFormTexts,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormText,
|
||||
requiredIntegerFormValue,
|
||||
} from '@/lib/formData'
|
||||
|
|
@ -49,6 +50,10 @@ export const updateContentPieceFormAction = async (
|
|||
municipalityId: municipalityValue,
|
||||
institution: nullableFormText(formData, 'institution'),
|
||||
transcript: nullableFormText(formData, 'transcript'),
|
||||
// S37 — the people order is the assessoria's: the first chip leads the
|
||||
// card, so the submit order is preserved (no id sort).
|
||||
leaderIds: repeatedRelationshipFormValues(formData, 'leaderIds', { sort: false }),
|
||||
publicFigures: repeatedFormTexts(formData, 'publicFigures'),
|
||||
})
|
||||
|
||||
revalidatePath(CAMPAIGN_COMMUNICATION_CONTEUDOS)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type ContentPieceCuratedField,
|
||||
type ContentPieceViewModel,
|
||||
} from '@/lib/contentPiece'
|
||||
import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog'
|
||||
import {
|
||||
CONTENT_PIECE_FORBIDDEN_MESSAGE,
|
||||
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
|
||||
|
|
@ -29,6 +30,10 @@ import type { SpeechTopic } from '@/lib/speechFacets'
|
|||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { reapStaleContentPiece } from '@/utilities/content/contentPieceJob'
|
||||
import {
|
||||
searchContentPieceLeaderOptions,
|
||||
type ContentPieceLeaderOption,
|
||||
} from '@/utilities/content/contentPieceLeaderOptions'
|
||||
import { startContentPieceJobInBackground } from '@/utilities/content/contentPieceScheduler'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
|
|
@ -125,6 +130,8 @@ export const updateContentPieceForActor = async (
|
|||
municipality: parsed.municipalityId ?? null,
|
||||
institution: parsed.institution ?? null,
|
||||
transcript: parsed.transcript ?? null,
|
||||
leaders: parsed.leaderIds ?? [],
|
||||
publicFigures: normalizeContentPiecePublicFigures(parsed.publicFigures ?? []),
|
||||
curatedFields: [...curated],
|
||||
},
|
||||
depth: 0,
|
||||
|
|
@ -315,3 +322,19 @@ export const addContentPieceByLinkForActor = async (input: {
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S37 — the ficha's leader picker search. Same communication gate as every
|
||||
* action of the vertical; the returned options carry ONLY `{ id, label }` (the
|
||||
* display-name projection the int tests assert), never any other leadership
|
||||
* field.
|
||||
*/
|
||||
export const searchContentPieceLeaderOptionsForActor = async (
|
||||
query: string,
|
||||
): Promise<ContentPieceLeaderOption[]> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
||||
|
||||
return searchContentPieceLeaderOptions(payload, actor, query)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { CampaignFooter } from '@/components/CampaignFooter'
|
||||
import { ContentPieceCatalog } from '@/components/conteudos/ContentPieceCatalog'
|
||||
import { ContentPieceFilters } from '@/components/conteudos/ContentPieceFilters'
|
||||
import {
|
||||
ContentPieceActiveFilterChip,
|
||||
ContentPieceFilters,
|
||||
} from '@/components/conteudos/ContentPieceFilters'
|
||||
import { ContentPieceHero } from '@/components/conteudos/ContentPieceHero'
|
||||
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
|
||||
import {
|
||||
|
|
@ -9,6 +12,7 @@ import {
|
|||
ContentPieceThemeFallbackNotice,
|
||||
ContentPieceThemeNoResults,
|
||||
} from '@/components/conteudos/ContentPieceStates'
|
||||
import { CONTENT_PIECE_ACTIVE_CHIP } from '@/components/conteudos/contentPieceClasses'
|
||||
import {
|
||||
CONTENT_PIECE_CATALOG_PATH,
|
||||
buildContentPieceCatalogHref,
|
||||
|
|
@ -105,6 +109,10 @@ export default async function ConteudosPage({
|
|||
)
|
||||
}
|
||||
|
||||
// S37 (design scene 03): the people filter leads the results with its own
|
||||
// heading and the active chip at the side (desktop).
|
||||
const liderancaFilter = activeFilters.find((filter) => filter.facet === 'lideranca')
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentPiecePageHeader />
|
||||
|
|
@ -129,13 +137,24 @@ export default async function ConteudosPage({
|
|||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black tracking-[-0.02em] sm:text-2xl">
|
||||
{themeMode ? `Resultados para “${params.q}”` : 'Escolha, compartilhe e peça voto'}
|
||||
{liderancaFilter
|
||||
? `Peças com ${liderancaFilter.value}`
|
||||
: themeMode
|
||||
? `Resultados para “${params.q}”`
|
||||
: 'Escolha, compartilhe e peça voto'}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-(--campaign-muted)">
|
||||
O play só carrega a mídia quando você pedir.
|
||||
{liderancaFilter
|
||||
? 'O filtro combina com cidade, tema e os demais recortes.'
|
||||
: 'O play só carrega a mídia quando você pedir.'}
|
||||
</p>
|
||||
</div>
|
||||
{themeMode ? (
|
||||
{liderancaFilter ? (
|
||||
<ContentPieceActiveFilterChip
|
||||
filter={liderancaFilter}
|
||||
className={`${CONTENT_PIECE_ACTIVE_CHIP} max-sm:hidden`}
|
||||
/>
|
||||
) : themeMode ? (
|
||||
themeApplied ? (
|
||||
<span className="text-sm text-(--campaign-muted)">Por tema ativo</span>
|
||||
) : null
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ import {
|
|||
CONTENT_PIECE_CURATED_FIELDS,
|
||||
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
|
||||
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
|
||||
CONTENT_PIECE_LEADERS_MAX,
|
||||
CONTENT_PIECE_LINK_FAILURE_REASONS,
|
||||
CONTENT_PIECE_ORIGINS,
|
||||
CONTENT_PIECE_PROCESSING_STATUSES,
|
||||
CONTENT_PIECE_PUBLIC_FIGURES_MAX,
|
||||
CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH,
|
||||
CONTENT_PIECE_STATUSES,
|
||||
CONTENT_PIECE_STEPS,
|
||||
CONTENT_PIECE_TITLE_MAX_LENGTH,
|
||||
|
|
@ -27,6 +30,8 @@ import {
|
|||
contentPieceStepLabels,
|
||||
contentPieceTypeLabels,
|
||||
} from '@/lib/contentPiece'
|
||||
import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog'
|
||||
import { uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
import { payloadAdminOnly } from '@/utilities/access/shared'
|
||||
import {
|
||||
|
|
@ -36,6 +41,7 @@ import {
|
|||
canUpdateContentPiece,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
|
||||
import { resolveContentPieceLeaderNames } from '@/utilities/content/contentPieceLeaderOptions'
|
||||
import { revalidateContentPiecesListing } from '@/utilities/documents'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
|
|
@ -190,6 +196,23 @@ const deriveContentPieceCatalogIndex: CollectionBeforeValidateHook = async ({
|
|||
|
||||
data.cityLabel = cityLabel
|
||||
data.region = region
|
||||
|
||||
// S37 — "who appears in the piece". The leader snapshot is recomputed only
|
||||
// when the relation was TOUCHED (the ficha always sends the list, an empty
|
||||
// one clearing it); a partial update keeps the stored names. The public
|
||||
// figures are canonicalized against the catalog on every write, so any path
|
||||
// (ficha, admin, direct update) stores the same spelling.
|
||||
if (data.leaders !== undefined) {
|
||||
data.leaderNames = await resolveContentPieceLeaderNames(
|
||||
req.payload,
|
||||
req,
|
||||
uniqueRelationshipIds(data.leaders),
|
||||
)
|
||||
}
|
||||
if (data.publicFigures !== undefined) {
|
||||
data.publicFigures = normalizeContentPiecePublicFigures(data.publicFigures ?? [])
|
||||
}
|
||||
|
||||
data.searchText = contentPieceSearchText({
|
||||
title: data.title ?? originalDoc?.title,
|
||||
description: data.description ?? originalDoc?.description,
|
||||
|
|
@ -197,6 +220,8 @@ const deriveContentPieceCatalogIndex: CollectionBeforeValidateHook = async ({
|
|||
institution: data.institution ?? originalDoc?.institution,
|
||||
topics: data.topics ?? originalDoc?.topics,
|
||||
cityLabel,
|
||||
leaderNames: data.leaderNames ?? originalDoc?.leaderNames,
|
||||
publicFigures: data.publicFigures ?? originalDoc?.publicFigures,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
|
@ -311,6 +336,40 @@ export const ContentPiece: CollectionConfig = {
|
|||
label: 'Instituição',
|
||||
maxLength: CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
|
||||
},
|
||||
{
|
||||
name: 'leaders',
|
||||
type: 'relationship',
|
||||
relationTo: 'leadership',
|
||||
hasMany: true,
|
||||
label: 'Lideranças da campanha',
|
||||
maxRows: CONTENT_PIECE_LEADERS_MAX,
|
||||
admin: {
|
||||
description:
|
||||
'Quem aparece na peça entre as lideranças já registradas. À Central pública vai apenas o nome.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'leaderNames',
|
||||
type: 'text',
|
||||
hasMany: true,
|
||||
label: 'Nomes para a Central pública',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Derivado das lideranças marcadas; é o que a faceta pública mostra.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publicFigures',
|
||||
type: 'text',
|
||||
hasMany: true,
|
||||
label: 'Figuras públicas',
|
||||
maxRows: CONTENT_PIECE_PUBLIC_FIGURES_MAX,
|
||||
maxLength: CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH,
|
||||
admin: {
|
||||
description:
|
||||
'Dobradinhas/estaduais do catálogo e outras personalidades; a grafia é canonicalizada ao salvar.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pieceDate',
|
||||
type: 'date',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { startTransition, useActionState, useState, type FormEvent } from 'react'
|
||||
|
||||
import { ContentPiecePeopleField } from '@/components/campaign/content/ContentPiecePeopleField'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
|
|
@ -21,6 +22,7 @@ import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets'
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import type { ContentPieceLeaderOption } from '@/utilities/content/contentPieceLeaderOptions'
|
||||
|
||||
type MunicipalityOption = { id: number; name: string }
|
||||
|
||||
|
|
@ -29,11 +31,15 @@ type MunicipalityOption = { id: number; name: string }
|
|||
* catalogue the pipeline filled, ready for the assessoria to revise. Saving
|
||||
* marks the sent fields as curated, so the automatic cataloguing never
|
||||
* overwrites them (D6). The transcript is a first-class field — it is what the
|
||||
* public search (S28) indexes.
|
||||
* public search (S28) indexes. S37 adds "Quem aparece na peça" between Temas
|
||||
* and Cidade (approved design scene 01A).
|
||||
*/
|
||||
export const ContentPieceForm = ({
|
||||
piece,
|
||||
municipalityOptions,
|
||||
leaderOptions,
|
||||
publicFigures,
|
||||
searchLeaders,
|
||||
formAction,
|
||||
}: {
|
||||
piece: {
|
||||
|
|
@ -48,6 +54,12 @@ export const ContentPieceForm = ({
|
|||
transcript: string | null
|
||||
}
|
||||
municipalityOptions: readonly MunicipalityOption[]
|
||||
/** S37 — the picked leaders, resolved to `{ id, label }` for the chips. */
|
||||
leaderOptions: readonly ContentPieceLeaderOption[]
|
||||
/** S37 — the curated public figures already on the piece. */
|
||||
publicFigures: readonly string[]
|
||||
/** S37 — the gated async search behind the leader picker. */
|
||||
searchLeaders: (query: string) => Promise<ContentPieceLeaderOption[]>
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
|
|
@ -157,6 +169,12 @@ export const ContentPieceForm = ({
|
|||
</div>
|
||||
</Field>
|
||||
|
||||
<ContentPiecePeopleField
|
||||
leaders={leaderOptions}
|
||||
publicFigures={publicFigures}
|
||||
searchLeaders={searchLeaders}
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="content-piece-municipality">Cidade</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
|
|||
263
src/components/campaign/content/ContentPiecePeopleField.tsx
Normal file
263
src/components/campaign/content/ContentPiecePeopleField.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
'use client'
|
||||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { useAsyncSearchOptions } from '@/hooks/useAsyncSearchOptions'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import { CONTENT_PIECE_LEADERS_MAX, CONTENT_PIECE_PUBLIC_FIGURES_MAX } from '@/lib/contentPiece'
|
||||
import { filterPublicFigures } from '@/lib/publicFigureCatalog'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import type { ContentPieceLeaderOption } from '@/utilities/content/contentPieceLeaderOptions'
|
||||
|
||||
/** One removable chip of the two recortes (design scenes 01/02). */
|
||||
const PeopleChip = ({ label, onRemove }: { label: string; onRemove: () => void }) => (
|
||||
<Badge variant="secondary" className="gap-1 pr-1 font-normal">
|
||||
<span className="max-w-full truncate">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex size-5 items-center justify-center after:absolute after:-inset-x-1 after:-inset-y-3"
|
||||
aria-label={`Remover ${label}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<XIcon className="size-3 opacity-70" aria-hidden="true" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
|
||||
/**
|
||||
* S37 — "Quem aparece na peça" (approved design scenes 01/01A/02): the two
|
||||
* curated recortes inside the C211 ficha. The leader picker searches the
|
||||
* existing records through a gated server action (async, 2+ chars, only the
|
||||
* display name); the public-figure picker filters the static catalog and
|
||||
* accepts free text ("Usar «…»"). Both keep removable chips and repeated hidden
|
||||
* inputs — the ficha nests no person record, only display names.
|
||||
*/
|
||||
export const ContentPiecePeopleField = ({
|
||||
leaders,
|
||||
publicFigures,
|
||||
searchLeaders,
|
||||
}: {
|
||||
leaders: readonly ContentPieceLeaderOption[]
|
||||
publicFigures: readonly string[]
|
||||
searchLeaders: (query: string) => Promise<ContentPieceLeaderOption[]>
|
||||
}) => {
|
||||
const [selectedLeaders, setSelectedLeaders] = useState<ContentPieceLeaderOption[]>([...leaders])
|
||||
const [selectedFigures, setSelectedFigures] = useState<string[]>([...publicFigures])
|
||||
const [leaderQuery, setLeaderQuery] = useState('')
|
||||
const [figureQuery, setFigureQuery] = useState('')
|
||||
|
||||
const {
|
||||
options: leaderResults,
|
||||
loading: leadersLoading,
|
||||
failed: leadersFailed,
|
||||
} = useAsyncSearchOptions<ContentPieceLeaderOption>({
|
||||
open: true,
|
||||
query: leaderQuery,
|
||||
search: searchLeaders,
|
||||
isQueryReady: isContactSearchQueryReady,
|
||||
})
|
||||
|
||||
const selectedLeaderIds = new Set(selectedLeaders.map((leader) => leader.id))
|
||||
const selectedFigureKeys = new Set(selectedFigures.map(normalizeForSearch))
|
||||
const atLeaderCapacity = selectedLeaders.length >= CONTENT_PIECE_LEADERS_MAX
|
||||
const atFigureCapacity = selectedFigures.length >= CONTENT_PIECE_PUBLIC_FIGURES_MAX
|
||||
|
||||
const toggleLeader = (option: ContentPieceLeaderOption) => {
|
||||
setSelectedLeaders((current) => {
|
||||
if (current.some((leader) => leader.id === option.id)) {
|
||||
return current.filter((leader) => leader.id !== option.id)
|
||||
}
|
||||
return atLeaderCapacity ? current : [...current, option]
|
||||
})
|
||||
}
|
||||
|
||||
const toggleFigure = (name: string) => {
|
||||
const trimmed = name.trim()
|
||||
const key = normalizeForSearch(trimmed)
|
||||
if (!key) return
|
||||
|
||||
setSelectedFigures((current) => {
|
||||
if (current.some((figure) => normalizeForSearch(figure) === key)) {
|
||||
return current.filter((figure) => normalizeForSearch(figure) !== key)
|
||||
}
|
||||
return atFigureCapacity ? current : [...current, trimmed]
|
||||
})
|
||||
}
|
||||
|
||||
const figureResults = filterPublicFigures(figureQuery)
|
||||
const figureText = figureQuery.trim()
|
||||
const canUseFigureText =
|
||||
figureText !== '' &&
|
||||
!selectedFigureKeys.has(normalizeForSearch(figureText)) &&
|
||||
!atFigureCapacity
|
||||
const nothingMarked = selectedLeaders.length === 0 && selectedFigures.length === 0
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-4 rounded-xl border border-black/12 p-5 sm:col-span-2">
|
||||
<legend className="sr-only">Quem aparece na peça</legend>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Quem aparece na peça</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Marque somente pessoas visíveis ou participantes desta peça. A publicação expõe apenas o
|
||||
nome.
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#fff5ed] px-2 py-1 text-[11px] font-semibold text-[#6b2d15]">
|
||||
Curadoria humana
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedLeaders.map((leader) => (
|
||||
<input key={leader.id} type="hidden" name="leaderIds" value={leader.id} />
|
||||
))}
|
||||
{selectedFigures.map((figure) => (
|
||||
<input key={figure} type="hidden" name="publicFigures" value={figure} />
|
||||
))}
|
||||
|
||||
{nothingMarked ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-xs leading-5 text-muted-foreground">
|
||||
Ninguém marcado. A peça pode ser publicada normalmente e não cria nenhum nome na faceta
|
||||
“Lideranças”.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold">Lideranças da campanha</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Busca nos registros existentes. Nenhum telefone, município ou voto vai para a Central
|
||||
pública.
|
||||
</p>
|
||||
{selectedLeaders.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{selectedLeaders.map((leader) => (
|
||||
<PeopleChip
|
||||
key={leader.id}
|
||||
label={leader.label}
|
||||
onRemove={() => toggleLeader(leader)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="mt-2 overflow-hidden rounded-xl border bg-white p-1 shadow-lg"
|
||||
>
|
||||
<CommandInput
|
||||
value={leaderQuery}
|
||||
onValueChange={setLeaderQuery}
|
||||
placeholder="Buscar liderança…"
|
||||
aria-label="Buscar liderança"
|
||||
/>
|
||||
<CommandList aria-busy={leadersLoading}>
|
||||
{leadersLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Spinner aria-hidden="true" />
|
||||
Buscando…
|
||||
</div>
|
||||
) : leadersFailed ? (
|
||||
<p className="py-6 text-center text-sm text-destructive">
|
||||
Não foi possível concluir a busca.
|
||||
</p>
|
||||
) : leaderResults.length > 0 ? (
|
||||
leaderResults.map((option) => {
|
||||
const selected = selectedLeaderIds.has(option.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={String(option.id)}
|
||||
data-checked={selected}
|
||||
disabled={atLeaderCapacity && !selected}
|
||||
onSelect={() => toggleLeader(option)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{option.label}</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">
|
||||
Registro da campanha
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{!isContactSearchQueryReady(leaderQuery)
|
||||
? 'Digite ao menos dois caracteres para buscar.'
|
||||
: 'Nenhum resultado encontrado.'}
|
||||
</p>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold">Figuras públicas</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Texto curado com catálogo, no mesmo molde de Instituição.
|
||||
</p>
|
||||
{selectedFigures.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{selectedFigures.map((figure) => (
|
||||
<PeopleChip key={figure} label={figure} onRemove={() => toggleFigure(figure)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="mt-2 overflow-hidden rounded-xl border bg-white p-1 shadow-lg"
|
||||
>
|
||||
<CommandInput
|
||||
value={figureQuery}
|
||||
onValueChange={setFigureQuery}
|
||||
placeholder="Digite ou escolha uma figura pública…"
|
||||
aria-label="Buscar ou digitar uma figura pública"
|
||||
/>
|
||||
<CommandList>
|
||||
{figureResults.map((entry) => {
|
||||
const selected = selectedFigureKeys.has(normalizeForSearch(entry.name))
|
||||
return (
|
||||
<CommandItem
|
||||
key={entry.slug}
|
||||
value={entry.slug}
|
||||
data-checked={selected}
|
||||
disabled={atFigureCapacity && !selected}
|
||||
onSelect={() => toggleFigure(entry.name)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{entry.name}</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">
|
||||
Figura pública catalogada
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{canUseFigureText ? (
|
||||
<CommandItem
|
||||
value={`usar-${figureText}`}
|
||||
className="font-medium text-primary"
|
||||
onSelect={() => {
|
||||
toggleFigure(figureText)
|
||||
setFigureQuery('')
|
||||
}}
|
||||
>
|
||||
Usar «{figureText}»
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{figureResults.length === 0 && !canUseFigureText ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado encontrado.
|
||||
</p>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
|
@ -24,6 +24,33 @@ const FIELD_CLASS =
|
|||
const DROPDOWN_LINK =
|
||||
'flex min-h-9 items-center rounded-lg px-2.5 text-sm font-medium text-black hover:bg-(--campaign-band) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)'
|
||||
|
||||
/**
|
||||
* S37 — the active-filter chip is shared with the results header (design scene
|
||||
* 03): on desktop the `Lideranças` chip lives next to the contextual heading,
|
||||
* on mobile it stays in the facet row.
|
||||
*/
|
||||
export const ContentPieceActiveFilterChip = ({
|
||||
filter,
|
||||
className,
|
||||
prefetch,
|
||||
}: {
|
||||
filter: ContentPieceCatalogActiveFilter
|
||||
className?: string
|
||||
prefetch?: boolean
|
||||
}) => (
|
||||
<Link
|
||||
href={filter.removeHref}
|
||||
prefetch={prefetch}
|
||||
aria-label={`Remover filtro ${filter.label}: ${filter.value}`}
|
||||
className={className ?? CONTENT_PIECE_ACTIVE_CHIP}
|
||||
>
|
||||
{filter.label} · {filter.value}
|
||||
<span aria-hidden="true" className="text-base leading-none">
|
||||
×
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
/**
|
||||
* S28 — the catalogue filters (artefato: cenas 07–09): a GET form (works
|
||||
* without JS, the URL is the state) with the search term, the mode segmented
|
||||
|
|
@ -65,7 +92,7 @@ export const ContentPieceFilters = ({
|
|||
type="search"
|
||||
name="q"
|
||||
defaultValue={params.q}
|
||||
placeholder="Encontre uma peça por assunto, cidade ou tema…"
|
||||
placeholder="Encontre uma peça por assunto, cidade ou pessoa…"
|
||||
aria-label="Buscar peças"
|
||||
className={FIELD_CLASS}
|
||||
/>
|
||||
|
|
@ -83,23 +110,23 @@ export const ContentPieceFilters = ({
|
|||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
|
||||
{facetFilters.map((filter) => (
|
||||
<Link
|
||||
<ContentPieceActiveFilterChip
|
||||
key={filter.facet}
|
||||
href={filter.removeHref}
|
||||
filter={filter}
|
||||
prefetch={themeMode ? false : undefined}
|
||||
aria-label={`Remover filtro ${filter.label}: ${filter.value}`}
|
||||
className={CONTENT_PIECE_ACTIVE_CHIP}
|
||||
>
|
||||
{filter.label} · {filter.value}
|
||||
<span aria-hidden="true" className="text-base leading-none">
|
||||
×
|
||||
</span>
|
||||
</Link>
|
||||
// S37 — on desktop the Lideranças chip moves next to the results
|
||||
// heading (design scene 03); mobile keeps it in the row.
|
||||
className={
|
||||
filter.facet === 'lideranca'
|
||||
? `${CONTENT_PIECE_ACTIVE_CHIP} sm:hidden`
|
||||
: CONTENT_PIECE_ACTIVE_CHIP
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{CONTENT_PIECE_CATALOG_FACETS.map((facet) =>
|
||||
params[facet] || facets[facet].length === 0 ? null : (
|
||||
<details key={facet} className="relative">
|
||||
<details key={facet} className="relative max-sm:open:w-full">
|
||||
<summary
|
||||
className={`${CONTENT_PIECE_CHIP} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
|
||||
>
|
||||
|
|
@ -108,13 +135,32 @@ export const ContentPieceFilters = ({
|
|||
⌄
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="absolute z-20 mt-1 max-h-72 min-w-44 list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1 shadow-[0_12px_30px_rgb(0_0_0/15%)]">
|
||||
<ul
|
||||
className={`absolute z-20 mt-1 max-h-72 list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1 shadow-[0_12px_30px_rgb(0_0_0/15%)] max-sm:static max-sm:mt-2 max-sm:w-full max-sm:min-w-0 max-sm:p-2 ${
|
||||
facet === 'lideranca' ? 'sm:w-64' : 'min-w-44'
|
||||
}`}
|
||||
>
|
||||
{facet === 'lideranca' ? (
|
||||
// S37 (design scene 03): the facet derives from published
|
||||
// pieces alone — the caption states the rule; the mobile
|
||||
// panel names the facet it belongs to.
|
||||
<>
|
||||
<li className="px-2.5 py-1 text-[10px] font-black tracking-wide text-(--campaign-muted) uppercase sm:hidden">
|
||||
Lideranças · Em peças publicadas
|
||||
</li>
|
||||
<li className="hidden px-2.5 py-1 text-[10px] font-black tracking-wide text-(--campaign-muted) uppercase sm:block">
|
||||
Em peças publicadas
|
||||
</li>
|
||||
</>
|
||||
) : null}
|
||||
{facets[facet].map((option) => (
|
||||
<li key={option.value}>
|
||||
<Link
|
||||
href={buildContentPieceCatalogHref({ ...params, [facet]: option.value })}
|
||||
prefetch={themeMode ? false : undefined}
|
||||
className={DROPDOWN_LINK}
|
||||
className={`${DROPDOWN_LINK}${
|
||||
facet === 'lideranca' ? ' sm:px-3 sm:py-2' : ''
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -141,6 +141,17 @@ export const CONTENT_PIECE_TITLE_MAX_LENGTH = 200
|
|||
export const CONTENT_PIECE_DESCRIPTION_MAX_LENGTH = 1200
|
||||
export const CONTENT_PIECE_INSTITUTION_MAX_LENGTH = 160
|
||||
|
||||
/**
|
||||
* S37 — "who appears in the piece": the campaign leaders linked to the
|
||||
* existing `leadership` records and the curated public figures. The limits are
|
||||
* the same on every path (collection, form schema and the picker).
|
||||
*/
|
||||
export const CONTENT_PIECE_LEADERS_MAX = 20
|
||||
export const CONTENT_PIECE_PUBLIC_FIGURES_MAX = 20
|
||||
export const CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH = 120
|
||||
/** The gated name search never returns more than this many options. */
|
||||
export const CONTENT_PIECE_LEADER_SEARCH_LIMIT = 20
|
||||
|
||||
/** Batch policy: a generous ceiling with a clear message above it (product A). */
|
||||
export const CONTENT_PIECE_BATCH_MAX_FILES = 50
|
||||
|
||||
|
|
@ -313,12 +324,15 @@ type ContentPieceSearchInput = {
|
|||
institution?: string | null
|
||||
topics?: readonly string[] | null
|
||||
cityLabel?: string | null
|
||||
/** S37 — the display names of who appears in the piece (leader snapshot + figures). */
|
||||
leaderNames?: readonly string[] | null
|
||||
publicFigures?: readonly string[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The normalized haystack the list search matches: title, description, the
|
||||
* transcript/text, the topic labels and the city/institution — everything the
|
||||
* design's placeholder promises ("Buscar por título, tema ou cidade…").
|
||||
* The normalized haystack the public/busy search matches: title, description,
|
||||
* the transcript/text, the topic labels, the city/institution and — since S37 —
|
||||
* the names of who appears in the piece, so searching a name finds the piece.
|
||||
*/
|
||||
export const contentPieceSearchText = (input: ContentPieceSearchInput): string =>
|
||||
normalizeForSearch(
|
||||
|
|
@ -329,6 +343,8 @@ export const contentPieceSearchText = (input: ContentPieceSearchInput): string =
|
|||
input.institution ?? '',
|
||||
...(input.topics ?? []).filter(isContentPieceTopic).map(contentPieceTopicLabel),
|
||||
input.cityLabel ?? '',
|
||||
...(input.leaderNames ?? []),
|
||||
...(input.publicFigures ?? []),
|
||||
]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const CONTENT_PIECE_CATALOG_FACETS = [
|
|||
'regiao',
|
||||
'tema',
|
||||
'instituicao',
|
||||
'lideranca',
|
||||
] as const
|
||||
|
||||
export type ContentPieceCatalogFacet = (typeof CONTENT_PIECE_CATALOG_FACETS)[number]
|
||||
|
|
@ -59,6 +60,7 @@ export const contentPieceCatalogFacetLabels: Record<ContentPieceCatalogFacet, st
|
|||
regiao: 'Região',
|
||||
tema: 'Tema',
|
||||
instituicao: 'Instituição',
|
||||
lideranca: 'Lideranças',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,6 +76,8 @@ export type ContentPieceCatalogParams = {
|
|||
regiao: string | null
|
||||
tema: SpeechTopic | null
|
||||
instituicao: string | null
|
||||
/** S37 — one display name present in the piece (`slugify` of the name). */
|
||||
lideranca: string | null
|
||||
q: string
|
||||
mode: ContentPieceCatalogMode | null
|
||||
}
|
||||
|
|
@ -106,6 +110,7 @@ export const parseContentPieceCatalogParams = (
|
|||
regiao: slugFacet('regiao'),
|
||||
tema: isContentPieceTopic(tema) ? tema : null,
|
||||
instituicao: slugFacet('instituicao'),
|
||||
lideranca: slugFacet('lideranca'),
|
||||
q,
|
||||
// Only `tema` is meaningful; anything else (and the default) means exact,
|
||||
// and a mode without a term has nothing to expand.
|
||||
|
|
@ -123,6 +128,7 @@ export const buildContentPieceCatalogHref = (
|
|||
if (params.regiao) search.set('regiao', params.regiao)
|
||||
if (params.tema) search.set('tema', params.tema)
|
||||
if (params.instituicao) search.set('instituicao', params.instituicao)
|
||||
if (params.lideranca) search.set('lideranca', params.lideranca)
|
||||
const term = params.q?.trim()
|
||||
if (term) {
|
||||
search.set('q', term)
|
||||
|
|
@ -154,6 +160,30 @@ const sortedOptions = (options: Map<string, string>): ContentPieceCatalogFacetOp
|
|||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'))
|
||||
|
||||
/** S37 — one display name of who appears in the piece (facet option shape). */
|
||||
export type ContentPiecePublicPerson = { slug: string; name: string }
|
||||
|
||||
/**
|
||||
* S37 — the single facet `Lideranças` union: the campaign-leader name snapshot
|
||||
* plus the curated public figures, each as a display name keyed by `slugify`.
|
||||
* Homonyms and spelling variants collapse into ONE option by design (the facet
|
||||
* filters by display name, never by identity; the last spelling of the stable
|
||||
* order labels it). Derived from the item alone — the public read never joins
|
||||
* `leadership`/`Contact`.
|
||||
*/
|
||||
export const contentPiecePublicPeople = (
|
||||
item: Pick<ContentPiecePublicItem, 'leaderNames' | 'publicFigures'>,
|
||||
): ContentPiecePublicPerson[] => {
|
||||
const people = new Map<string, string>()
|
||||
for (const value of [...item.leaderNames, ...item.publicFigures]) {
|
||||
const name = value.trim()
|
||||
const slug = slugify(name)
|
||||
if (!name || !slug) continue
|
||||
people.set(slug, name)
|
||||
}
|
||||
return [...people.entries()].map(([slug, name]) => ({ slug, name }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Facet options are derived from the board items alone: a filter that cannot
|
||||
* match anything never renders. Tipo/Tema keep the persisted enum order; the
|
||||
|
|
@ -171,6 +201,7 @@ export const contentPieceCatalogFacets = (
|
|||
const cities = new Map<string, string>()
|
||||
const regions = new Map<string, string>()
|
||||
const institutions = new Map<string, string>()
|
||||
const people = new Map<string, string>()
|
||||
|
||||
for (const item of items) {
|
||||
if (isCardCatalogItem(item)) {
|
||||
|
|
@ -182,6 +213,7 @@ export const contentPieceCatalogFacets = (
|
|||
if (item.cityLabel) cities.set(slugify(item.cityLabel), item.cityLabel)
|
||||
if (item.regionLabel) regions.set(slugify(item.regionLabel), item.regionLabel)
|
||||
if (item.institution) institutions.set(slugify(item.institution), item.institution)
|
||||
for (const person of contentPiecePublicPeople(item)) people.set(person.slug, person.name)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -196,6 +228,7 @@ export const contentPieceCatalogFacets = (
|
|||
cidade: sortedOptions(cities),
|
||||
regiao: sortedOptions(regions),
|
||||
instituicao: sortedOptions(institutions),
|
||||
lideranca: sortedOptions(people),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +315,9 @@ export const filterContentPieceCatalogItems = (
|
|||
(item.regionLabel === null || slugify(item.regionLabel) !== params.regiao)) ||
|
||||
(params.tema && !item.topics.includes(params.tema)) ||
|
||||
(params.instituicao &&
|
||||
(item.institution === null || slugify(item.institution) !== params.instituicao))
|
||||
(item.institution === null || slugify(item.institution) !== params.instituicao)) ||
|
||||
(params.lideranca &&
|
||||
!contentPiecePublicPeople(item).some((person) => person.slug === params.lideranca))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -304,6 +339,10 @@ export type ContentPiecePublicSource = {
|
|||
cityLabel?: string | null
|
||||
region?: string | null
|
||||
institution?: string | null
|
||||
/** S37 — derived display names of the linked campaign leaders (never the relation). */
|
||||
leaderNames?: string[] | null
|
||||
/** S37 — curated public figures (display names). */
|
||||
publicFigures?: string[] | null
|
||||
description?: string | null
|
||||
transcript?: string | null
|
||||
sourceUrl?: string | null
|
||||
|
|
@ -329,12 +368,15 @@ export type ContentPiecePublicItem = {
|
|||
cityLabel: string | null
|
||||
regionLabel: string | null
|
||||
institution: string | null
|
||||
/** S37 — display names of who appears in the piece (leader snapshot + figures). */
|
||||
leaderNames: string[]
|
||||
publicFigures: string[]
|
||||
description: string | null
|
||||
/** Opening of the text/transcript, for the `texto` card asset. */
|
||||
excerpt: string | null
|
||||
durationLabel: string | null
|
||||
pieceDateLabel: string | null
|
||||
/** `Tema · Local` (or institution/city fallbacks) — the card's metadata line. */
|
||||
/** S37 — `Nome · Tema · Local` (or the previous fallbacks) — the card's metadata line. */
|
||||
metaLabel: string
|
||||
searchText: string
|
||||
/** S28 — why the piece appeared in the theme mode; null in the literal search. */
|
||||
|
|
@ -647,8 +689,18 @@ export const toContentPiecePublicItem = (
|
|||
const cityLabel = record.cityLabel?.trim() || null
|
||||
const regionLabel = record.region?.trim() || null
|
||||
const institution = record.institution?.trim() || null
|
||||
const leaderNames = (record.leaderNames ?? []).map((name) => name.trim()).filter(Boolean)
|
||||
const publicFigures = (record.publicFigures ?? []).map((name) => name.trim()).filter(Boolean)
|
||||
const local = cityLabel ?? regionLabel
|
||||
const metaLabel = [topicLabels[0] ?? institution, local].filter(Boolean).join(' · ')
|
||||
const metaLabel = [
|
||||
// S37 — who appears leads the metadata line; the previous shape stays for a
|
||||
// piece with nobody marked (byte-identical card).
|
||||
leaderNames[0] ?? publicFigures[0],
|
||||
topicLabels[0] ?? institution,
|
||||
local,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
const isLink = media === null && sourceUrl !== null
|
||||
|
||||
return {
|
||||
|
|
@ -666,6 +718,8 @@ export const toContentPiecePublicItem = (
|
|||
cityLabel,
|
||||
regionLabel,
|
||||
institution,
|
||||
leaderNames,
|
||||
publicFigures,
|
||||
description: record.description?.trim() || null,
|
||||
excerpt: contentPieceTextExcerpt(record.transcript),
|
||||
durationLabel: durationLabelOf(record.durationSeconds),
|
||||
|
|
|
|||
|
|
@ -151,14 +151,25 @@ export const nullableRelationshipFormValue = (
|
|||
return positiveInteger(field, entry.value)
|
||||
}
|
||||
|
||||
export const repeatedRelationshipFormValues = (formData: FormData, field: string): number[] => {
|
||||
/**
|
||||
* Relationship `hasMany` values from the form, deduped. Order is the value
|
||||
* order (ascending id) for the scoped selects; `{ sort: false }` keeps the
|
||||
* submission order for the lists where the user's order is meaningful (the
|
||||
* S37 people chips — the first name leads the public card).
|
||||
*/
|
||||
export const repeatedRelationshipFormValues = (
|
||||
formData: FormData,
|
||||
field: string,
|
||||
{ sort = true }: { sort?: boolean } = {},
|
||||
): number[] => {
|
||||
const values = formData.getAll(field).map((value) => {
|
||||
if (typeof value !== 'string') {
|
||||
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
|
||||
}
|
||||
return positiveInteger(field, value)
|
||||
})
|
||||
return [...new Set(values)].sort((left, right) => left - right)
|
||||
const unique = [...new Set(values)]
|
||||
return sort ? unique.sort((left, right) => left - right) : unique
|
||||
}
|
||||
|
||||
export const repeatedFormTexts = (formData: FormData, field: string): string[] => {
|
||||
|
|
|
|||
111
src/lib/publicFigureCatalog.ts
Normal file
111
src/lib/publicFigureCatalog.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* S37 — static catalog of the public figures the assessoria may mark on a piece
|
||||
* ("quem aparece na peça", recorte 2): the state-deputy roster (S30, the
|
||||
* campaign's dobradinhas) plus other personalities curated as data. It follows
|
||||
* the `institution` mold — free text canonicalized against a catalog — without
|
||||
* any new person record: the piece stores display names, never identities.
|
||||
*
|
||||
* The dobradinha rows are DERIVED from `stateDeputyCatalog` (the roster stays
|
||||
* the owner of the 53 names); the slug carries a kind prefix so a personality
|
||||
* can never collide with a roster entry. No I/O and no `server-only`: the ficha
|
||||
* picker, the collection hook and the unit tests share this module.
|
||||
*/
|
||||
import {
|
||||
CONTENT_PIECE_PUBLIC_FIGURES_MAX,
|
||||
CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH,
|
||||
} from '@/lib/contentPiece'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import { stateDeputyCatalog } from '@/lib/stateDeputyCatalog'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
type PublicFigureKind = 'dobradinha' | 'personalidade'
|
||||
|
||||
export type PublicFigureCatalogEntry = {
|
||||
/** Catalog identity (`<kind>-<slug>`), never a public URL. */
|
||||
slug: string
|
||||
/** Display name the piece stores and the public facet shows. */
|
||||
name: string
|
||||
kind: PublicFigureKind
|
||||
}
|
||||
|
||||
/**
|
||||
* Other public figures beyond the dobradinhas roster. Deliberately EMPTY on
|
||||
* v1: inventing names would be product data without provenance; a personality
|
||||
* is marked as free text and becomes data to curate when the gate asks for it.
|
||||
*/
|
||||
export const PUBLIC_FIGURE_PERSONALITIES: readonly string[] = []
|
||||
|
||||
const personalitySlug = (name: string): string => `personalidade-${slugify(name)}`
|
||||
|
||||
export const publicFigureCatalog: readonly PublicFigureCatalogEntry[] = [
|
||||
...stateDeputyCatalog.map((deputy) => ({
|
||||
slug: `estadual-${deputy.slug}`,
|
||||
name: deputy.name,
|
||||
kind: 'dobradinha' as const,
|
||||
})),
|
||||
...PUBLIC_FIGURE_PERSONALITIES.map((name) => ({
|
||||
slug: personalitySlug(name),
|
||||
name,
|
||||
kind: 'personalidade' as const,
|
||||
})),
|
||||
]
|
||||
|
||||
const normalizedEntryValues = (entry: PublicFigureCatalogEntry): string[] => [
|
||||
normalizeSearchPhrase(entry.name),
|
||||
normalizeSearchPhrase(entry.slug),
|
||||
]
|
||||
|
||||
/**
|
||||
* Accent/case/punctuation-insensitive search by display name or slug; an empty
|
||||
* query keeps all (the picker opens with the roster). Same normalization as
|
||||
* `resolvePublicFigureName`, so what the picker finds is what the save
|
||||
* canonicalizes. Same contract as the S30 selector.
|
||||
*/
|
||||
export const filterPublicFigures = (query: string): readonly PublicFigureCatalogEntry[] => {
|
||||
const needle = normalizeSearchPhrase(query)
|
||||
if (!needle) return publicFigureCatalog
|
||||
|
||||
return publicFigureCatalog.filter((entry) =>
|
||||
normalizedEntryValues(entry).some((value) => value.includes(needle)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical display name of a typed value, or null when it matches nothing
|
||||
* or more than one catalog entry (ambiguous input is never guessed).
|
||||
*/
|
||||
export const resolvePublicFigureName = (value: string): string | null => {
|
||||
const normalized = normalizeSearchPhrase(value)
|
||||
if (!normalized) return null
|
||||
|
||||
const matches = publicFigureCatalog.filter((entry) =>
|
||||
normalizedEntryValues(entry).includes(normalized),
|
||||
)
|
||||
return matches.length === 1 ? matches[0].name : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical list the piece persists: trimmed, catalog spelling when the
|
||||
* value resolves, deduped by normalized form, capped. Overlong values are
|
||||
* dropped (the field is a display name, not a dump) — the form schema refuses
|
||||
* them first on the editable path.
|
||||
*/
|
||||
export const normalizeContentPiecePublicFigures = (values: readonly string[]): string[] => {
|
||||
const seen = new Set<string>()
|
||||
const figures: string[] = []
|
||||
|
||||
for (const raw of values) {
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : ''
|
||||
if (!trimmed || trimmed.length > CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH) continue
|
||||
|
||||
const name = resolvePublicFigureName(trimmed) ?? trimmed
|
||||
const key = normalizeSearchPhrase(name)
|
||||
if (!key || seen.has(key)) continue
|
||||
|
||||
seen.add(key)
|
||||
figures.push(name)
|
||||
if (figures.length >= CONTENT_PIECE_PUBLIC_FIGURES_MAX) break
|
||||
}
|
||||
|
||||
return figures
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ import { z } from 'zod'
|
|||
import {
|
||||
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
|
||||
CONTENT_PIECE_INSTITUTION_MAX_LENGTH,
|
||||
CONTENT_PIECE_LEADERS_MAX,
|
||||
CONTENT_PIECE_PUBLIC_FIGURES_MAX,
|
||||
CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH,
|
||||
CONTENT_PIECE_TEXT_TOO_LARGE_MESSAGE,
|
||||
CONTENT_PIECE_TITLE_MAX_LENGTH,
|
||||
CONTENT_PIECE_TYPES,
|
||||
|
|
@ -90,6 +93,13 @@ export const contentPieceUpdateRequestSchema = z.object({
|
|||
municipalityId: z.number().int().positive().nullable().optional(),
|
||||
institution: trimmedNullableText(CONTENT_PIECE_INSTITUTION_MAX_LENGTH),
|
||||
transcript: trimmedNullableText(200_000),
|
||||
// S37 — who appears in the piece: the leader relation ids and the curated
|
||||
// public figures. The action canonicalizes the figures against the catalog.
|
||||
leaderIds: z.array(z.number().int().positive()).max(CONTENT_PIECE_LEADERS_MAX).optional(),
|
||||
publicFigures: z
|
||||
.array(z.string().trim().max(CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH))
|
||||
.max(CONTENT_PIECE_PUBLIC_FIGURES_MAX)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type ContentPieceUpdateRequest = z.infer<typeof contentPieceUpdateRequestSchema>
|
||||
|
|
|
|||
18494
src/migrations/20260924_123940_add_content_piece_people.json
Normal file
18494
src/migrations/20260924_123940_add_content_piece_people.json
Normal file
File diff suppressed because it is too large
Load diff
35
src/migrations/20260924_123940_add_content_piece_people.ts
Normal file
35
src/migrations/20260924_123940_add_content_piece_people.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "content_piece_texts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer NOT NULL,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"text" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "content_piece_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"leadership_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "content_piece_texts" ADD CONSTRAINT "content_piece_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."content_piece"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "content_piece_rels" ADD CONSTRAINT "content_piece_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."content_piece"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "content_piece_rels" ADD CONSTRAINT "content_piece_rels_leadership_fk" FOREIGN KEY ("leadership_id") REFERENCES "public"."leadership"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "content_piece_texts_order_parent" ON "content_piece_texts" USING btree ("order","parent_id");
|
||||
CREATE INDEX "content_piece_rels_order_idx" ON "content_piece_rels" USING btree ("order");
|
||||
CREATE INDEX "content_piece_rels_parent_idx" ON "content_piece_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "content_piece_rels_path_idx" ON "content_piece_rels" USING btree ("path");
|
||||
CREATE INDEX "content_piece_rels_leadership_id_idx" ON "content_piece_rels" USING btree ("leadership_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "content_piece_texts" CASCADE;
|
||||
DROP TABLE "content_piece_rels" CASCADE;`)
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ import * as migration_20260923_190414_add_content_event_variant from './20260923
|
|||
import * as migration_20260924_040210_add_recording_facets from './20260924_040210_add_recording_facets';
|
||||
import * as migration_20260924_042028_add_web_speech_origin from './20260924_042028_add_web_speech_origin';
|
||||
import * as migration_20260924_045625_add_content_piece_link_failure_reason from './20260924_045625_add_content_piece_link_failure_reason';
|
||||
import * as migration_20260924_123940_add_content_piece_people from './20260924_123940_add_content_piece_people';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -510,16 +511,21 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260924_040210_add_recording_facets.up,
|
||||
down: migration_20260924_040210_add_recording_facets.down,
|
||||
name: '20260924_040210_add_recording_facets'
|
||||
name: '20260924_040210_add_recording_facets',
|
||||
},
|
||||
{
|
||||
up: migration_20260924_042028_add_web_speech_origin.up,
|
||||
down: migration_20260924_042028_add_web_speech_origin.down,
|
||||
name: '20260924_042028_add_web_speech_origin'
|
||||
name: '20260924_042028_add_web_speech_origin',
|
||||
},
|
||||
{
|
||||
up: migration_20260924_045625_add_content_piece_link_failure_reason.up,
|
||||
down: migration_20260924_045625_add_content_piece_link_failure_reason.down,
|
||||
name: '20260924_045625_add_content_piece_link_failure_reason'
|
||||
name: '20260924_045625_add_content_piece_link_failure_reason',
|
||||
},
|
||||
{
|
||||
up: migration_20260924_123940_add_content_piece_people.up,
|
||||
down: migration_20260924_123940_add_content_piece_people.down,
|
||||
name: '20260924_123940_add_content_piece_people',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1266,6 +1266,18 @@ export interface ContentPiece {
|
|||
*/
|
||||
region?: string | null;
|
||||
institution?: string | null;
|
||||
/**
|
||||
* Quem aparece na peça entre as lideranças já registradas. À Central pública vai apenas o nome.
|
||||
*/
|
||||
leaders?: (number | Leadership)[] | null;
|
||||
/**
|
||||
* Derivado das lideranças marcadas; é o que a faceta pública mostra.
|
||||
*/
|
||||
leaderNames?: string[] | null;
|
||||
/**
|
||||
* Dobradinhas/estaduais do catálogo e outras personalidades; a grafia é canonicalizada ao salvar.
|
||||
*/
|
||||
publicFigures?: string[] | null;
|
||||
pieceDate?: string | null;
|
||||
/**
|
||||
* Medida pelo provedor de transcrição; nunca estimada.
|
||||
|
|
@ -2849,6 +2861,9 @@ export interface ContentPieceSelect<T extends boolean = true> {
|
|||
cityLabel?: T;
|
||||
region?: T;
|
||||
institution?: T;
|
||||
leaders?: T;
|
||||
leaderNames?: T;
|
||||
publicFigures?: T;
|
||||
pieceDate?: T;
|
||||
durationSeconds?: T;
|
||||
transcript?: T;
|
||||
|
|
|
|||
130
src/utilities/content/contentPieceLeaderOptions.ts
Normal file
130
src/utilities/content/contentPieceLeaderOptions.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
|
||||
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
|
||||
import { CONTENT_PIECE_LEADER_SEARCH_LIMIT } from '@/lib/contentPiece'
|
||||
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
|
||||
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
|
||||
|
||||
export type ContentPieceLeaderOption = { id: number; label: string }
|
||||
|
||||
const leaderOptionOf = (leadership: Leadership): ContentPieceLeaderOption | null => {
|
||||
const id = relationshipId(leadership.id)
|
||||
const contact = leadership.contact
|
||||
if (!id || !isPopulatedRelationship<Contact>(contact)) return null
|
||||
const label = contact.name.trim()
|
||||
return label ? { id, label } : null
|
||||
}
|
||||
|
||||
/** The options in the requested id order, skipping ids that no longer resolve. */
|
||||
const orderOptionsById = (
|
||||
options: readonly ContentPieceLeaderOption[],
|
||||
ids: readonly number[],
|
||||
): ContentPieceLeaderOption[] => {
|
||||
const byId = new Map(options.map((option) => [option.id, option]))
|
||||
return ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((option): option is ContentPieceLeaderOption => option !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one read of `leadership` behind the ficha picker and the name snapshot.
|
||||
* The `select` reads only the contact relation (the source of the label) and
|
||||
* the `{ id, label }` mapping is the boundary that guarantees only the display
|
||||
* name leaves — no phone, municipality, organization, status or votes of a
|
||||
* leadership is returned (the int tests assert the returned shape).
|
||||
*/
|
||||
const findLeaderOptions = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
req: PayloadRequest | undefined,
|
||||
where: Parameters<Payload['find']>[0]['where'],
|
||||
limit: number,
|
||||
): Promise<ContentPieceLeaderOption[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
where,
|
||||
depth: 1,
|
||||
limit,
|
||||
select: { contact: true },
|
||||
// S37 intentional admin bypass (approved in the plan, D1): the candidate
|
||||
// list needs the display name of leaderships the communication role cannot
|
||||
// read through the RBAC (it is the role that edits the ficha). Only the
|
||||
// name of the projection above is read or returned; `overrideAccess: false`
|
||||
// would blind the picker for the very persona that operates the Central.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
return result.docs
|
||||
.map((doc) => leaderOptionOf(doc as Leadership))
|
||||
.filter((option): option is ContentPieceLeaderOption => option !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected leaders' `{ id, label }` in the requested order (the ficha
|
||||
* chips). Fail-closed: a role outside the communication gate gets an empty
|
||||
* list, never a leaked name.
|
||||
*/
|
||||
export const resolveContentPieceLeaderOptions = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
actor: Pick<CampaignUser, 'role'>,
|
||||
ids: readonly number[],
|
||||
): Promise<ContentPieceLeaderOption[]> => {
|
||||
if (!canReadCommunicationCatalog(actor.role)) return []
|
||||
|
||||
const uniqueIds = [...new Set(ids)]
|
||||
if (uniqueIds.length === 0) return []
|
||||
|
||||
const options = await findLeaderOptions(
|
||||
payload,
|
||||
undefined,
|
||||
{ id: { in: uniqueIds } },
|
||||
uniqueIds.length,
|
||||
)
|
||||
return orderOptionsById(options, uniqueIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* The gated async search of the ficha picker (2+ chars, bounded page). Same
|
||||
* fail-closed gate as the resolver; the caller action maps the denial to the
|
||||
* vertical's forbidden message.
|
||||
*/
|
||||
export const searchContentPieceLeaderOptions = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
actor: Pick<CampaignUser, 'role'>,
|
||||
query: string,
|
||||
): Promise<ContentPieceLeaderOption[]> => {
|
||||
if (!canReadCommunicationCatalog(actor.role)) return []
|
||||
|
||||
const rawQuery = typeof query === 'string' ? query : ''
|
||||
if (!isContactSearchQueryReady(rawQuery)) return []
|
||||
|
||||
const { trimmed } = normalizeContactSearchQuery(rawQuery)
|
||||
const options = await findLeaderOptions(
|
||||
payload,
|
||||
undefined,
|
||||
{ 'contact.name': { contains: trimmed } },
|
||||
CONTENT_PIECE_LEADER_SEARCH_LIMIT,
|
||||
)
|
||||
return options.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
||||
}
|
||||
|
||||
/**
|
||||
* S37 — the system-context snapshot: the display names of the linked leaders,
|
||||
* in the relation's order, skipping members that no longer resolve. Called by
|
||||
* the collection hook (inside the caller's transaction) to denormalize
|
||||
* `leaderNames`, so the public read never joins `leadership`.
|
||||
*/
|
||||
export const resolveContentPieceLeaderNames = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
req: PayloadRequest,
|
||||
ids: readonly number[],
|
||||
): Promise<string[]> => {
|
||||
const uniqueIds = [...new Set(ids)]
|
||||
if (uniqueIds.length === 0) return []
|
||||
|
||||
const options = await findLeaderOptions(payload, req, { id: { in: uniqueIds } }, uniqueIds.length)
|
||||
return orderOptionsById(options, uniqueIds).map((option) => option.label)
|
||||
}
|
||||
|
|
@ -9,10 +9,15 @@ import {
|
|||
type ContentPieceCirculationCounts,
|
||||
type ContentPieceRowViewModel,
|
||||
} from '@/lib/contentPieceCirculation'
|
||||
import { uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { CONTENT_PIECE_NOT_FOUND_MESSAGE } from '@/lib/schemas/contentPiece'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { type RawSearchParams } from '@/utilities/campaignListUrl'
|
||||
import { loadContentEventCountsBySubject } from '@/utilities/content/contentEventAggregate'
|
||||
import {
|
||||
resolveContentPieceLeaderOptions,
|
||||
type ContentPieceLeaderOption,
|
||||
} from '@/utilities/content/contentPieceLeaderOptions'
|
||||
import {
|
||||
buildContentPieceListWhere,
|
||||
contentPiecePageSize,
|
||||
|
|
@ -54,6 +59,9 @@ const contentPieceDetailSelect = {
|
|||
municipality: true,
|
||||
curatedFields: true,
|
||||
linkFailureReason: true,
|
||||
// S37 — the picked leaders (ids) and the curated figures the ficha renders.
|
||||
leaders: true,
|
||||
publicFigures: true,
|
||||
} as const
|
||||
|
||||
export const ContentPieceNotFoundError = createEntityNotFoundError(
|
||||
|
|
@ -173,7 +181,11 @@ export type ContentPieceDetailPageData = {
|
|||
pieceDate: string | null
|
||||
municipalityId: number | null
|
||||
curatedFields: string[]
|
||||
/** S37 — the curated public figures (display names). */
|
||||
publicFigures: string[]
|
||||
}
|
||||
/** S37 — `{ id, label }` of the picked leaders, for the ficha chips. */
|
||||
leaderOptions: ContentPieceLeaderOption[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -200,7 +212,11 @@ export const loadContentPieceDetailPageData = async (
|
|||
const piece = result.docs[0]
|
||||
if (!piece) throw new ContentPieceNotFoundError()
|
||||
|
||||
const circulation = await loadCirculationLookup(payload, [piece])
|
||||
const leaderIds = uniqueRelationshipIds(piece.leaders)
|
||||
const [circulation, leaderOptions] = await Promise.all([
|
||||
loadCirculationLookup(payload, [piece]),
|
||||
resolveContentPieceLeaderOptions(payload, user, leaderIds),
|
||||
])
|
||||
const viewModel = toContentPieceRow(piece, circulation)
|
||||
return {
|
||||
piece: {
|
||||
|
|
@ -215,6 +231,8 @@ export const loadContentPieceDetailPageData = async (
|
|||
? piece.municipality
|
||||
: (piece.municipality?.id ?? null),
|
||||
curatedFields: piece.curatedFields ?? [],
|
||||
publicFigures: piece.publicFigures ?? [],
|
||||
},
|
||||
leaderOptions,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ const contentPiecePublicSelect = {
|
|||
cityLabel: true,
|
||||
region: true,
|
||||
institution: true,
|
||||
// S37 — only the display-name snapshot and the curated figures; the
|
||||
// `leaders` relation NEVER enters this select (depth 1 would leak Contact).
|
||||
leaderNames: true,
|
||||
publicFigures: true,
|
||||
description: true,
|
||||
transcript: true,
|
||||
sourceUrl: true,
|
||||
|
|
|
|||
|
|
@ -1490,6 +1490,47 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
expect(afterUnpublish.media).toBeTruthy()
|
||||
})
|
||||
|
||||
test('the ficha renders who appears in the piece for the communicator (S37)', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('pecapessoas')
|
||||
const { piece } = await createPiece(campaign, { marker })
|
||||
const municipality = await campaign.fixtures.claimMunicipality()
|
||||
const { contactName, leadershipId } = await campaign.fixtures.createStaffLeadership({
|
||||
namePrefix: 'Liderança',
|
||||
municipalities: [municipality],
|
||||
})
|
||||
await campaign.fixtures.payload.update({
|
||||
collection: 'contentPiece',
|
||||
id: piece.id,
|
||||
data: { leaders: [leadershipId], publicFigures: ['dra elaine'] },
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
const response = await request.get(`/campanha/comunicacao/conteudos/${piece.id}`)
|
||||
expect(response.status()).toBe(200)
|
||||
const html = rendered(await response.text())
|
||||
expect(html).toContain('Quem aparece na peça')
|
||||
expect(html).toContain('Lideranças da campanha')
|
||||
expect(html).toContain('Figuras públicas')
|
||||
expect(html).toContain('Curadoria humana')
|
||||
// The chip labels come from the projection; the figure was canonicalized.
|
||||
expect(html).toContain(contactName)
|
||||
expect(html).toContain('Dra. Elaine')
|
||||
|
||||
// The advisor stays denied on the whole communication vertical.
|
||||
const advisor = await campaign.fixtures.createCampaignUser('advisor')
|
||||
const advisorRequest = await campaignRequest(advisor, advisor.password)
|
||||
await assertCampaignRedirect(
|
||||
advisorRequest,
|
||||
`/campanha/comunicacao/conteudos/${piece.id}`,
|
||||
'/campanha',
|
||||
)
|
||||
})
|
||||
|
||||
test('the list and the ficha show the anonymous circulation counters', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { APIRequestContext, Page } from '@playwright/test'
|
|||
|
||||
import { loadMunicipalityGeometryModule } from '../../src/lib/bahiaGeometries.js'
|
||||
import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
|
||||
import { slugify } from '../../src/lib/slug.js'
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { interiorPointOf } from '../helpers/featureBounds'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
|
|
@ -37,6 +38,8 @@ type Headers = Record<string, string>
|
|||
|
||||
const createdPieceIds: number[] = []
|
||||
const createdMediaIds: number[] = []
|
||||
const createdContactIds: number[] = []
|
||||
const createdLeadershipIds: number[] = []
|
||||
|
||||
const uniqueMarker = () => randomUUID().slice(0, 8)
|
||||
|
||||
|
|
@ -81,6 +84,9 @@ const createPiece = async (
|
|||
sourceUrl?: string
|
||||
/** S39 — the home sample resolves the município name from this relation. */
|
||||
municipality?: number
|
||||
/** S37 — the campaign leaders and curated figures this piece carries. */
|
||||
leaders?: number[]
|
||||
publicFigures?: string[]
|
||||
},
|
||||
): Promise<{ id: number; slug: string }> => {
|
||||
const mediaKind = data.type === 'video' || data.type === 'foto' ? data.type : 'texto'
|
||||
|
|
@ -106,6 +112,8 @@ const createPiece = async (
|
|||
...(media ? { media } : {}),
|
||||
...(data.sourceUrl ? { sourceUrl: data.sourceUrl } : {}),
|
||||
...(data.municipality ? { municipality: data.municipality } : {}),
|
||||
...(data.leaders ? { leaders: data.leaders } : {}),
|
||||
...(data.publicFigures ? { publicFigures: data.publicFigures } : {}),
|
||||
},
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
|
|
@ -114,6 +122,41 @@ const createPiece = async (
|
|||
return { id: doc.id, slug: doc.slug }
|
||||
}
|
||||
|
||||
/**
|
||||
* S37 — a real leadership record (contact + municipality link) for the public
|
||||
* "Lideranças" facet: the piece references the record, only the name is public.
|
||||
*/
|
||||
const createLeadershipWithContact = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
name: string,
|
||||
): Promise<{ leadershipId: number }> => {
|
||||
const contactResponse = await request.post(`${BASE_URL}/api/contact`, {
|
||||
headers,
|
||||
data: { name },
|
||||
})
|
||||
expect(contactResponse.ok(), await contactResponse.text()).toBeTruthy()
|
||||
const contactId = ((await contactResponse.json()) as { doc: { id: number } }).doc.id
|
||||
createdContactIds.push(contactId)
|
||||
|
||||
const municipalityResponse = await request.get(
|
||||
`${BASE_URL}/api/municipality?limit=1&depth=0&sort=name`,
|
||||
{ headers },
|
||||
)
|
||||
expect(municipalityResponse.ok(), await municipalityResponse.text()).toBeTruthy()
|
||||
const municipalityId = ((await municipalityResponse.json()) as { docs: { id: number }[] })
|
||||
.docs[0]!.id
|
||||
|
||||
const leadershipResponse = await request.post(`${BASE_URL}/api/leadership`, {
|
||||
headers,
|
||||
data: { contact: contactId, municipalities: [municipalityId] },
|
||||
})
|
||||
expect(leadershipResponse.ok(), await leadershipResponse.text()).toBeTruthy()
|
||||
const leadershipId = ((await leadershipResponse.json()) as { doc: { id: number } }).doc.id
|
||||
createdLeadershipIds.push(leadershipId)
|
||||
return { leadershipId }
|
||||
}
|
||||
|
||||
const setPieceStatus = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
|
|
@ -192,6 +235,12 @@ test.afterAll(async ({ request }) => {
|
|||
for (const id of createdMediaIds.splice(0)) {
|
||||
await request.delete(`${BASE_URL}/api/contentMedia/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
for (const id of createdLeadershipIds.splice(0)) {
|
||||
await request.delete(`${BASE_URL}/api/leadership/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
for (const id of createdContactIds.splice(0)) {
|
||||
await request.delete(`${BASE_URL}/api/contact/${id}`, { headers }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Frontend Central de Conteúdos (S27)', () => {
|
||||
|
|
@ -696,4 +745,57 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
await expect(section.getByText('Do seu município', { exact: true })).toBeVisible()
|
||||
await expect(section.getByRole('link', { name: title })).toBeVisible()
|
||||
})
|
||||
test('filters by who appears in the piece and removes a name when unpublished (S37)', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const headers = await adminHeaders(request, BASE_URL)
|
||||
await unpublishEveryPiece(request, headers)
|
||||
const marker = uniqueMarker()
|
||||
const leaderName = `Maria Silva ${marker}`
|
||||
const personSlug = slugify(leaderName)
|
||||
const title = `Giro com a liderança ${marker}`
|
||||
const { leadershipId } = await createLeadershipWithContact(request, headers, leaderName)
|
||||
const piece = await createPiece(request, headers, {
|
||||
title,
|
||||
type: 'video',
|
||||
leaders: [leadershipId],
|
||||
publicFigures: ['dra elaine'],
|
||||
})
|
||||
|
||||
await page.goto('/conteudos')
|
||||
await waitForSettledPage(page)
|
||||
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
|
||||
// The card's metadata line leads with who appears in the piece.
|
||||
await expect(
|
||||
page.locator('article[data-content-piece]').getByText(leaderName, { exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
// The one "Lideranças" facet unions the leader name and the curated figure.
|
||||
await page.locator('summary').filter({ hasText: 'Lideranças' }).first().click()
|
||||
await expect(
|
||||
page.getByText('Em peças publicadas').filter({ visible: true }).first(),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Dra. Elaine', exact: true })).toBeVisible()
|
||||
await page.getByRole('link', { name: leaderName, exact: true }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`lideranca=${personSlug}$`))
|
||||
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByRole('link', { name: /Remover filtro Lideranças/ })
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible()
|
||||
|
||||
// Searching the name finds the piece (the haystack carries it).
|
||||
await page.goto(`/conteudos?q=${encodeURIComponent(leaderName)}`)
|
||||
await waitForSettledPage(page)
|
||||
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
|
||||
|
||||
// Unpublishing the last piece with the name removes it from the facet.
|
||||
await setPieceStatus(request, headers, piece.id, 'rascunho')
|
||||
await page.goto('/conteudos')
|
||||
await waitForSettledPage(page)
|
||||
await expect(page.locator('summary').filter({ hasText: 'Lideranças' })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
addContentPieceByLinkForActor,
|
||||
getContentPieceStatusesForActor,
|
||||
retryContentPieceForActor,
|
||||
searchContentPieceLeaderOptionsForActor,
|
||||
setContentPiecePublishedForActor,
|
||||
updateContentPieceForActor,
|
||||
} from '@/app/(campaign)/campanha/actions/contentPieces'
|
||||
|
|
@ -48,6 +49,7 @@ import {
|
|||
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
|
||||
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
|
||||
} from '@/lib/schemas/contentPiece'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import type { SpeechTopic } from '@/lib/speechFacets'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import type { CampaignUser, ContentMedia } from '@/payload-types'
|
||||
|
|
@ -66,6 +68,7 @@ import { loadContentPieceListPageData } from '@/utilities/content/contentPiecePa
|
|||
import {
|
||||
getPublishedContentPieceBySlug,
|
||||
getPublishedContentPieceItems,
|
||||
getPublishedContentPieceRecords,
|
||||
hasPublishedContentPieces,
|
||||
} from '@/utilities/content/contentPieceReads'
|
||||
import { loadContentPieceCatalogSearch } from '@/utilities/content/contentPieceThemeSearch'
|
||||
|
|
@ -743,6 +746,123 @@ describe('content pieces (C211)', () => {
|
|||
expect(row.searchText).toContain('camara dos deputados')
|
||||
})
|
||||
|
||||
it('searches the leader options with the display-name projection only (S37)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const marker = `busca-${Date.now()}`
|
||||
const contact = await fixtures.createContact({ name: `Maria Silva ${marker}` })
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
})
|
||||
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
const options = await searchContentPieceLeaderOptionsForActor(`Maria Silva ${marker}`)
|
||||
const found = options.find((option) => option.id === leadership.id)
|
||||
|
||||
// The projection is exactly `{ id, label }` — no other leadership field
|
||||
// (municipality, status, votes, contact) crosses the boundary.
|
||||
expect(found).toEqual({ id: leadership.id, label: contact.name })
|
||||
expect(Object.keys(found!).sort()).toEqual(['id', 'label'])
|
||||
|
||||
// Shorter than 2 chars never queries.
|
||||
expect(await searchContentPieceLeaderOptionsForActor('M')).toEqual([])
|
||||
|
||||
// The role gate fails closed for every non-communication role.
|
||||
for (const denied of [advisor, leader]) {
|
||||
getCampaignUserMock.mockResolvedValue(denied)
|
||||
await expect(
|
||||
searchContentPieceLeaderOptionsForActor(`Maria Silva ${marker}`),
|
||||
).rejects.toThrow(CONTENT_PIECE_FORBIDDEN_MESSAGE)
|
||||
}
|
||||
})
|
||||
|
||||
it('saves who appears in the piece, snapshots names and survives the pipeline (S37)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const marker = `pessoas-${Date.now()}`
|
||||
const contact = await fixtures.createContact({ name: `Liderança ${marker}` })
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
})
|
||||
const { piece } = await createPiece({
|
||||
processingStatus: 'processando',
|
||||
title: `Peça pessoas ${marker}`,
|
||||
})
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
|
||||
await updateContentPieceForActor({
|
||||
contentPieceId: piece.id,
|
||||
title: `Peça pessoas ${marker}`,
|
||||
description: null,
|
||||
type: 'video',
|
||||
pieceDate: null,
|
||||
municipalityId: null,
|
||||
institution: null,
|
||||
transcript: null,
|
||||
leaderIds: [leadership.id],
|
||||
// A typed variant resolves to the catalog spelling; the free text stays.
|
||||
publicFigures: ['dra elaine', 'Personalidade Sem Catálogo'],
|
||||
})
|
||||
|
||||
const row = await payload.findByID({
|
||||
collection: 'contentPiece',
|
||||
id: piece.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(row.leaders).toEqual([leadership.id])
|
||||
expect(row.leaderNames).toEqual([contact.name])
|
||||
expect(row.publicFigures).toEqual(['Dra. Elaine', 'Personalidade Sem Catálogo'])
|
||||
expect(row.searchText).toContain(normalizeForSearch(contact.name))
|
||||
expect(row.searchText).toContain('dra. elaine')
|
||||
|
||||
// The pipeline job never touches the curation fields.
|
||||
const restore = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
||||
try {
|
||||
await runContentPieceJob(payload, piece.id, {
|
||||
transcribe: transcribeOk,
|
||||
catalog: catalogStub,
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
const processed = await payload.findByID({
|
||||
collection: 'contentPiece',
|
||||
id: piece.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(processed.leaders).toEqual([leadership.id])
|
||||
expect(processed.leaderNames).toEqual([contact.name])
|
||||
expect(processed.publicFigures).toEqual(['Dra. Elaine', 'Personalidade Sem Catálogo'])
|
||||
|
||||
// Clearing the picks clears the snapshot, the figures and the haystack.
|
||||
await updateContentPieceForActor({
|
||||
contentPieceId: piece.id,
|
||||
title: `Peça pessoas ${marker}`,
|
||||
description: null,
|
||||
type: 'video',
|
||||
pieceDate: null,
|
||||
municipalityId: null,
|
||||
institution: null,
|
||||
transcript: null,
|
||||
leaderIds: [],
|
||||
publicFigures: [],
|
||||
})
|
||||
const cleared = await payload.findByID({
|
||||
collection: 'contentPiece',
|
||||
id: piece.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(cleared.leaders).toEqual([])
|
||||
expect(cleared.leaderNames).toEqual([])
|
||||
expect(cleared.publicFigures).toEqual([])
|
||||
expect(cleared.searchText).not.toContain(normalizeForSearch(contact.name))
|
||||
})
|
||||
|
||||
it('derives the city and the region from the related município', async () => {
|
||||
const municipality = await payload.find({
|
||||
collection: 'municipality',
|
||||
|
|
@ -1257,6 +1377,77 @@ describe('content pieces (C211)', () => {
|
|||
expect(await hasPublishedContentPieces()).toBe(true)
|
||||
})
|
||||
|
||||
it('derives the people facet and the name search from published pieces only (S37)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const marker = `facet-${Date.now()}`
|
||||
const contactName = `Liderança Pública ${marker}`
|
||||
const contact = await fixtures.createContact({ name: contactName })
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
})
|
||||
|
||||
const published = await createPiece({ title: `Publicada pessoas ${marker}` })
|
||||
const draft = await createPiece({ title: `Rascunho pessoas ${marker}` })
|
||||
getCampaignUserMock.mockResolvedValue(communicator)
|
||||
|
||||
for (const [id, title] of [
|
||||
[published.piece.id, `Publicada pessoas ${marker}`],
|
||||
[draft.piece.id, `Rascunho pessoas ${marker}`],
|
||||
] as const) {
|
||||
await updateContentPieceForActor({
|
||||
contentPieceId: id,
|
||||
title,
|
||||
description: null,
|
||||
type: 'video',
|
||||
pieceDate: null,
|
||||
municipalityId: null,
|
||||
institution: null,
|
||||
transcript: null,
|
||||
leaderIds: [leadership.id],
|
||||
publicFigures: ['Dra. Elaine'],
|
||||
})
|
||||
}
|
||||
await payload.update({
|
||||
collection: 'contentPiece',
|
||||
id: published.piece.id,
|
||||
data: { status: 'publicado' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const personSlug = slugify(contactName)
|
||||
const records = await getPublishedContentPieceRecords()
|
||||
const record = records.find((candidate) => candidate.id === published.piece.id)
|
||||
expect(record?.leaderNames).toEqual([contactName])
|
||||
expect(record?.publicFigures).toEqual(['Dra. Elaine'])
|
||||
// The public select never carries the relation (depth 1 would leak Contact).
|
||||
expect(record && 'leaders' in record).toBe(false)
|
||||
|
||||
const filtered = await loadContentPieceCatalogSearch({
|
||||
rawSearchParams: { lideranca: personSlug },
|
||||
})
|
||||
expect(pieceRows(filtered.items).map((item) => item.id)).toContain(published.piece.id)
|
||||
expect(pieceRows(filtered.items).map((item) => item.id)).not.toContain(draft.piece.id)
|
||||
expect(filtered.facets.lideranca).toContainEqual({ value: personSlug, label: contactName })
|
||||
|
||||
const byName = await loadContentPieceCatalogSearch({ rawSearchParams: { q: contactName } })
|
||||
expect(pieceRows(byName.items).map((item) => item.id)).toContain(published.piece.id)
|
||||
|
||||
// Unpublishing the last piece with the name removes it from the facet.
|
||||
await payload.update({
|
||||
collection: 'contentPiece',
|
||||
id: published.piece.id,
|
||||
data: { status: 'rascunho' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const afterUnpublish = await loadContentPieceCatalogSearch({ rawSearchParams: {} })
|
||||
expect(afterUnpublish.facets.lideranca).not.toContainEqual({
|
||||
value: personSlug,
|
||||
label: contactName,
|
||||
})
|
||||
})
|
||||
|
||||
it('serves a published piece file publicly and 404s draft, link and unknown slugs', async () => {
|
||||
const marker = `media-${Date.now()}`
|
||||
const draft = await createPiece({ title: `Mídia rascunho ${marker}` })
|
||||
|
|
|
|||
|
|
@ -244,6 +244,16 @@ describe('contentPieceSearchText', () => {
|
|||
it('is empty when nothing is filled', () => {
|
||||
expect(contentPieceSearchText({})).toBe('')
|
||||
})
|
||||
|
||||
it('includes the display names of who appears in the piece (S37)', () => {
|
||||
expect(
|
||||
contentPieceSearchText({
|
||||
title: 'Giro',
|
||||
leaderNames: ['Maria Silva'],
|
||||
publicFigures: ['Dra. Elaine'],
|
||||
}),
|
||||
).toBe('giro maria silva dra. elaine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toContentPieceViewModel', () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
contentPieceMediaKind,
|
||||
contentPieceMediaPath,
|
||||
contentPiecePublicPath,
|
||||
contentPiecePublicPeople,
|
||||
contentPieceTextExcerpt,
|
||||
contentPieceThemeMatch,
|
||||
contentPieceThemeTerms,
|
||||
|
|
@ -65,6 +66,7 @@ describe('content piece catalog params', () => {
|
|||
regiao: 'metropolitano-de-salvador',
|
||||
tema: 'economia-trabalho',
|
||||
instituicao: 'camara-dos-deputados',
|
||||
lideranca: 'maria-silva',
|
||||
q: ' escala ',
|
||||
mode: 'tema',
|
||||
}),
|
||||
|
|
@ -74,6 +76,7 @@ describe('content piece catalog params', () => {
|
|||
regiao: 'metropolitano-de-salvador',
|
||||
tema: 'economia-trabalho',
|
||||
instituicao: 'camara-dos-deputados',
|
||||
lideranca: 'maria-silva',
|
||||
q: 'escala',
|
||||
mode: 'tema',
|
||||
})
|
||||
|
|
@ -84,10 +87,13 @@ describe('content piece catalog params', () => {
|
|||
regiao: null,
|
||||
tema: null,
|
||||
instituicao: null,
|
||||
lideranca: null,
|
||||
q: '',
|
||||
mode: null,
|
||||
})
|
||||
expect(parseContentPieceCatalogParams({ cidade: '../etc/passwd' }).cidade).toBeNull()
|
||||
// S37 — the people facet is a display-name slug; spaces/accents never pass.
|
||||
expect(parseContentPieceCatalogParams({ lideranca: 'Maria Silva' }).lideranca).toBeNull()
|
||||
expect(parseContentPieceCatalogParams({}).q).toBe('')
|
||||
})
|
||||
|
||||
|
|
@ -114,6 +120,14 @@ describe('content piece catalog params', () => {
|
|||
expect(buildContentPieceCatalogHref({ tipo: 'video', tema: null, q: '' })).toBe(
|
||||
'/conteudos?tipo=video',
|
||||
)
|
||||
// S37 — the people facet is appended after Instituição, before the query.
|
||||
expect(
|
||||
buildContentPieceCatalogHref({
|
||||
instituicao: 'camara-dos-deputados',
|
||||
lideranca: 'maria-silva',
|
||||
q: 'escala',
|
||||
}),
|
||||
).toBe('/conteudos?instituicao=camara-dos-deputados&lideranca=maria-silva&q=escala')
|
||||
expect(buildContentPieceCatalogHref({ q: 'escala', mode: 'tema' })).toBe(
|
||||
'/conteudos?q=escala&mode=tema',
|
||||
)
|
||||
|
|
@ -146,7 +160,7 @@ describe('content piece catalog params', () => {
|
|||
describe('content piece catalog facets', () => {
|
||||
it('derives options from published items only, in enum/alphabetical order', () => {
|
||||
const facets = contentPieceCatalogFacets([
|
||||
item(),
|
||||
item({ leaderNames: ['Maria Silva'] }),
|
||||
item({
|
||||
id: 2,
|
||||
slug: 'card-saude',
|
||||
|
|
@ -155,6 +169,7 @@ describe('content piece catalog facets', () => {
|
|||
cityLabel: 'Feira de Santana',
|
||||
region: 'Portal do Sertão',
|
||||
institution: 'Câmara dos Deputados',
|
||||
publicFigures: ['Dep. Estadual Exemplo'],
|
||||
}),
|
||||
])
|
||||
|
||||
|
|
@ -171,6 +186,23 @@ describe('content piece catalog facets', () => {
|
|||
expect(facets.instituicao).toEqual([
|
||||
{ value: 'camara-dos-deputados', label: 'Câmara dos Deputados' },
|
||||
])
|
||||
// S37 — one facet unions the leader snapshot and the curated figures,
|
||||
// alphabetical by the display name.
|
||||
expect(facets.lideranca).toEqual([
|
||||
{ value: 'dep-estadual-exemplo', label: 'Dep. Estadual Exemplo' },
|
||||
{ value: 'maria-silva', label: 'Maria Silva' },
|
||||
])
|
||||
})
|
||||
|
||||
it('collapses homonyms and spelling variants into one people option (S37)', () => {
|
||||
const facets = contentPieceCatalogFacets([
|
||||
item({ leaderNames: ['Dra. Elaine'] }),
|
||||
item({ id: 2, slug: 'outra', publicFigures: ['Dra Elaine'] }),
|
||||
])
|
||||
|
||||
expect(facets.lideranca).toEqual([{ value: 'dra-elaine', label: 'Dra Elaine' }])
|
||||
// No piece carries people: the facet renders nothing.
|
||||
expect(contentPieceCatalogFacets([item()]).lideranca).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -212,6 +244,28 @@ describe('content piece catalog filtering', () => {
|
|||
expect(idsFor({ tipo: 'video', q: 'escala' })).toEqual([1])
|
||||
})
|
||||
|
||||
it('filters by the people facet union and combines with the other facets (S37)', () => {
|
||||
const people = [
|
||||
item({ leaderNames: ['Maria Silva'] }),
|
||||
item({
|
||||
id: 2,
|
||||
slug: 'card-feira',
|
||||
type: 'card',
|
||||
cityLabel: 'Feira de Santana',
|
||||
publicFigures: ['Dep. Estadual Exemplo'],
|
||||
}),
|
||||
]
|
||||
const ids = (raw: Record<string, string>) =>
|
||||
pieceRows(filterContentPieceCatalogItems(people, parseContentPieceCatalogParams(raw))).map(
|
||||
(row) => row.id,
|
||||
)
|
||||
|
||||
expect(ids({ lideranca: 'maria-silva' })).toEqual([1])
|
||||
expect(ids({ lideranca: 'dep-estadual-exemplo' })).toEqual([2])
|
||||
expect(ids({ lideranca: 'maria-silva', tipo: 'card' })).toEqual([])
|
||||
expect(ids({ lideranca: 'ninguem' })).toEqual([])
|
||||
})
|
||||
|
||||
it('ORs the literal query with the expanded theme terms, facets still AND', () => {
|
||||
const idsForTerms = (raw: Record<string, string>, terms: string[]) =>
|
||||
pieceRows(
|
||||
|
|
@ -447,6 +501,31 @@ describe('content piece public view model', () => {
|
|||
expect(contentPieceMediaKind(item({ media: null, sourceUrl: 'https://youtu.be/X' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('leads the metadata line with who appears in the piece (S37)', () => {
|
||||
expect(item({ leaderNames: ['Maria Silva'] }).metaLabel).toBe(
|
||||
'Maria Silva · Economia e Trabalho · Salvador',
|
||||
)
|
||||
// A figure takes the slot when there is no leader name.
|
||||
expect(item({ publicFigures: ['Dep. Estadual Exemplo'] }).metaLabel).toBe(
|
||||
'Dep. Estadual Exemplo · Economia e Trabalho · Salvador',
|
||||
)
|
||||
// Nobody marked: the previous line stays byte-identical.
|
||||
expect(item().metaLabel).toBe('Economia e Trabalho · Salvador')
|
||||
})
|
||||
|
||||
it('unions leaders and figures as people options, deduped by display name (S37)', () => {
|
||||
expect(
|
||||
contentPiecePublicPeople({
|
||||
leaderNames: ['Maria Silva'],
|
||||
publicFigures: ['Maria Silva', 'Dep. Estadual Exemplo'],
|
||||
}),
|
||||
).toEqual([
|
||||
{ slug: 'maria-silva', name: 'Maria Silva' },
|
||||
{ slug: 'dep-estadual-exemplo', name: 'Dep. Estadual Exemplo' },
|
||||
])
|
||||
expect(contentPiecePublicPeople({ leaderNames: [], publicFigures: [' '] })).toEqual([])
|
||||
})
|
||||
|
||||
it('fails closed without a slug or without file and link', () => {
|
||||
expect(toContentPiecePublicItem(source({ slug: null }))).toBeNull()
|
||||
expect(toContentPiecePublicItem(source({ status: 'rascunho' }))).toBeNull()
|
||||
|
|
|
|||
111
tests/unit/contentPiecePeopleField.unit.spec.tsx
Normal file
111
tests/unit/contentPiecePeopleField.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ContentPiecePeopleField } from '@/components/campaign/content/ContentPiecePeopleField'
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
const renderField = (props: {
|
||||
leaders?: { id: number; label: string }[]
|
||||
publicFigures?: string[]
|
||||
searchLeaders?: (query: string) => Promise<{ id: number; label: string }[]>
|
||||
}) =>
|
||||
render(
|
||||
createElement(ContentPiecePeopleField, {
|
||||
leaders: props.leaders ?? [],
|
||||
publicFigures: props.publicFigures ?? [],
|
||||
searchLeaders: props.searchLeaders ?? vi.fn(async () => []),
|
||||
}),
|
||||
)
|
||||
|
||||
const hiddenValues = (container: HTMLElement, name: string): string[] =>
|
||||
[...container.querySelectorAll<HTMLInputElement>(`input[name="${name}"]`)].map(
|
||||
(input) => input.value,
|
||||
)
|
||||
|
||||
describe('ContentPiecePeopleField (S37)', () => {
|
||||
it('renders the picked people as chips plus hidden inputs, and the empty state when none', () => {
|
||||
const { container } = renderField({
|
||||
leaders: [{ id: 7, label: 'Maria Silva' }],
|
||||
publicFigures: ['Dra. Elaine'],
|
||||
})
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Quem aparece na peça' })).toBeTruthy()
|
||||
expect(screen.getByText('Curadoria humana')).toBeTruthy()
|
||||
expect(hiddenValues(container, 'leaderIds')).toEqual(['7'])
|
||||
expect(hiddenValues(container, 'publicFigures')).toEqual(['Dra. Elaine'])
|
||||
expect(screen.queryByText(/Ninguém marcado/)).toBeNull()
|
||||
|
||||
cleanup()
|
||||
renderField({})
|
||||
expect(screen.getByText(/Ninguém marcado/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('searches leaders through the async action, adds and removes the chip', async () => {
|
||||
const searchLeaders = vi.fn(async (query: string) => [{ id: 1, label: `Liderança ${query}` }])
|
||||
const { container } = renderField({ searchLeaders })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Buscar liderança'), { target: { value: 'Ma' } })
|
||||
const option = await screen.findByRole('option', { name: /Liderança Ma/ })
|
||||
expect(searchLeaders).toHaveBeenCalledWith('Ma')
|
||||
|
||||
fireEvent.click(option)
|
||||
await waitFor(() => expect(hiddenValues(container, 'leaderIds')).toEqual(['1']))
|
||||
expect(screen.getByLabelText('Remover Liderança Ma')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Remover Liderança Ma'))
|
||||
await waitFor(() => expect(hiddenValues(container, 'leaderIds')).toEqual([]))
|
||||
})
|
||||
|
||||
it('gates the leader search on two characters and reports an empty result', async () => {
|
||||
const searchLeaders = vi.fn(async () => [])
|
||||
renderField({ searchLeaders })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Buscar liderança'), { target: { value: 'M' } })
|
||||
expect(screen.getByText('Digite ao menos dois caracteres para buscar.')).toBeTruthy()
|
||||
await waitFor(() => expect(searchLeaders).not.toHaveBeenCalled())
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Buscar liderança'), { target: { value: 'Maria' } })
|
||||
await waitFor(() => expect(searchLeaders).toHaveBeenCalledTimes(1))
|
||||
expect(await screen.findByText('Nenhum resultado encontrado.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('canonicalizes a catalog figure and accepts free text through "Usar"', async () => {
|
||||
const { container } = renderField({ publicFigures: [] })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Buscar ou digitar uma figura pública'), {
|
||||
target: { value: 'elaine' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('option', { name: /Dra\. Elaine/ }))
|
||||
await waitFor(() => expect(hiddenValues(container, 'publicFigures')).toEqual(['Dra. Elaine']))
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Buscar ou digitar uma figura pública'), {
|
||||
target: { value: 'Personalidade Local' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('option', { name: /Usar «Personalidade Local»/ }))
|
||||
await waitFor(() =>
|
||||
expect(hiddenValues(container, 'publicFigures')).toEqual([
|
||||
'Dra. Elaine',
|
||||
'Personalidade Local',
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -69,6 +69,13 @@ describe('FormData boundary', () => {
|
|||
expect(repeatedRelationshipFormValues(formData, 'coordinators')).toEqual([3, 7, 12])
|
||||
})
|
||||
|
||||
it('keeps the submission order when the user order is meaningful (S37)', () => {
|
||||
const formData = new FormData()
|
||||
for (const value of ['12', '3', '12', '7']) formData.append('leaders', value)
|
||||
|
||||
expect(repeatedRelationshipFormValues(formData, 'leaders', { sort: false })).toEqual([12, 3, 7])
|
||||
})
|
||||
|
||||
it('distinguishes absent JSON from explicit empty arrays and rejects invalid inputs', () => {
|
||||
const formData = new FormData()
|
||||
expect(boundedJsonFormValue(formData, 'strengths', 100)).toBeUndefined()
|
||||
|
|
|
|||
68
tests/unit/publicFigureCatalog.unit.spec.ts
Normal file
68
tests/unit/publicFigureCatalog.unit.spec.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CONTENT_PIECE_PUBLIC_FIGURES_MAX,
|
||||
CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH,
|
||||
} from '@/lib/contentPiece'
|
||||
import {
|
||||
PUBLIC_FIGURE_PERSONALITIES,
|
||||
filterPublicFigures,
|
||||
normalizeContentPiecePublicFigures,
|
||||
publicFigureCatalog,
|
||||
resolvePublicFigureName,
|
||||
} from '@/lib/publicFigureCatalog'
|
||||
import { stateDeputyCatalog } from '@/lib/stateDeputyCatalog'
|
||||
|
||||
describe('public figure catalog (S37)', () => {
|
||||
it('derives every dobradinha from the S30 roster without copying the names', () => {
|
||||
expect(PUBLIC_FIGURE_PERSONALITIES).toEqual([])
|
||||
expect(publicFigureCatalog).toHaveLength(stateDeputyCatalog.length)
|
||||
expect(
|
||||
publicFigureCatalog.filter((entry) => entry.kind === 'dobradinha').map((entry) => entry.name),
|
||||
).toEqual(stateDeputyCatalog.map((deputy) => deputy.name))
|
||||
|
||||
const elaine = publicFigureCatalog.find((entry) => entry.name === 'Dra. Elaine')
|
||||
expect(elaine).toEqual({ slug: 'estadual-elane', name: 'Dra. Elaine', kind: 'dobradinha' })
|
||||
})
|
||||
|
||||
it('resolves a typed name or slug to the canonical spelling, case/accent-insensitive', () => {
|
||||
expect(resolvePublicFigureName('Dra. Elaine')).toBe('Dra. Elaine')
|
||||
expect(resolvePublicFigureName('dra elaine')).toBe('Dra. Elaine')
|
||||
expect(resolvePublicFigureName(' DRÁ ELAINE ')).toBe('Dra. Elaine')
|
||||
expect(resolvePublicFigureName('estadual-elane')).toBe('Dra. Elaine')
|
||||
// Unknown names stay free text (the picker's "use this text" path).
|
||||
expect(resolvePublicFigureName('Personalidade Sem Catálogo')).toBeNull()
|
||||
expect(resolvePublicFigureName('')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters by display name or slug, keeping the whole roster on an empty query', () => {
|
||||
expect(filterPublicFigures('')).toHaveLength(stateDeputyCatalog.length)
|
||||
expect(filterPublicFigures('elaine').map((entry) => entry.name)).toEqual(['Dra. Elaine'])
|
||||
expect(filterPublicFigures('coletivo').map((entry) => entry.slug)).toEqual(['estadual-adriana'])
|
||||
expect(filterPublicFigures('ninguém')).toEqual([])
|
||||
})
|
||||
|
||||
it('canonicalizes, dedupes and bounds the persisted figure list', () => {
|
||||
expect(
|
||||
normalizeContentPiecePublicFigures([
|
||||
' dra elaine ',
|
||||
'Dra. Elaine',
|
||||
'Personalidade Exemplo',
|
||||
'personalidade exemplo',
|
||||
'',
|
||||
' ',
|
||||
]),
|
||||
).toEqual(['Dra. Elaine', 'Personalidade Exemplo'])
|
||||
|
||||
expect(
|
||||
normalizeContentPiecePublicFigures(['x'.repeat(CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH + 1)]),
|
||||
).toEqual([])
|
||||
|
||||
const many = Array.from({ length: CONTENT_PIECE_PUBLIC_FIGURES_MAX + 5 }, (_, index) =>
|
||||
String(`Pessoa ${index + 1}`),
|
||||
)
|
||||
expect(normalizeContentPiecePublicFigures(many)).toHaveLength(CONTENT_PIECE_PUBLIC_FIGURES_MAX)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue