mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C154): vertical de comunicação — papel communicator e acervo pesquisável
Papel communicator com nav própria e gate speechCatalog; acervo em /campanha/comunicacao/acervo com busca normalizada (destaque no trecho), filtros de ano/tema/alcance/fase/município/duração em URL canônica e paginação por discurso via speech.searchText (GIN trgm + backfill). Detalhe com player nativo no trecho, transcrição clicável, baixar/abrir fonte e fallback sem VOD. Resolve o débito do C153 (Sollinha fora da vertical) e corrige o parser de keywords oficiais no dono. Closes #956
This commit is contained in:
parent
67a40a6c4b
commit
3e062749fa
50 changed files with 18614 additions and 85 deletions
1
docs/changelog/2026-09-13-c154.md
Normal file
1
docs/changelog/2026-09-13-c154.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C154 — Vertical Comunicação: papel communicator + Acervo pesquisável (2026-09-13):** o assessor de comunicação ganhou a vertical própria. `communicator` vê e navega só `/campanha/comunicacao` (nav própria; sem áreas de staff, sem Sollinha — o débito do C153 dos chips de líder foi resolvido escondendo as superfícies do assistente para esse papel via `canUseCampaignAssistant`); `coordinator`/`candidate` também veem; `advisor`/`leader` são redirecionados pelo gate novo `speechCatalog` do `requireCampaignPageActor`. O Acervo (`/campanha/comunicacao/acervo`) busca por termo com e sem acento sobre os segmentos ASR (destaque `<mark>` no trecho) e keywords oficiais como termo exato, com filtros de ano, tema, alcance, fase, município citado e duração em URL canônica (`resolveListUrl`/`CampaignListOmnibox`/`CampaignHeaderFilterPopover` com trigger em chip opt-in) e paginação **por discurso**: `speech.searchText` (concatenação normalizada dos segmentos, mantida pelo `upsertSpeechBundle`, coluna opcional + índice GIN `pg_trgm` hand-written com backfill em `20260913_150000_backfill_speech_search_text_trgm_index`) resolve o "N falas encontradas" sem dedupe entre páginas. O detalhe (`/campanha/comunicacao/acervo/[id]`) tem player `<video>` nativo que abre no trecho (`?t=`), transcrição ASR clicável com destaque, sumário/contexto da sessão, baixar VOD e abrir fonte, com fallback "VOD indisponível" e transcrição oficial em `<details>`. Highlight puro em `src/lib/speechHighlight.ts` (mapa offset normalizado→original), loader `speechPageData`, view models, omnibox e módulos de URL/filtros em `src/utilities/speech/`; parser `parseOfficialKeywords` do C153 corrigido no dono (a API separa keywords por vírgula dentro de grupos por linha — o card exibia um chip gigante). Provas: 20 unit novos + int `speechAcervo` (busca sem acento, keyword, facetas, paginação por discurso, RBAC) + extensão do int do import (`searchText` espelhado e preservado em `segments: undefined`); e2e `campaignSpeechAcervo` (6 cenários HTTP: nav/redirects, highlight, filtros, RBAC, VOD ausente) com entry no manifest; prova viva local com import real (3 discursos, 43 segmentos ASR, busca "saude" e detalhe no browser). Migration `20260913_145820_add_speech_search_text` + backfill/índice.
|
||||
216
docs/plans/acervo-videos-comunicacao-impl.md
Normal file
216
docs/plans/acervo-videos-comunicacao-impl.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
# Impl: Vertical Comunicação: papel communicator + Acervo pesquisável
|
||||
|
||||
Status: executado
|
||||
Atualizado em: 2026-09-13
|
||||
Issue: #956
|
||||
Intenção: docs/plans/acervo-videos-comunicacao.md
|
||||
Appetite restante: herdado (~2–3 dias; 1 outcome verificável — o assessor entra com acesso próprio, busca uma fala por tema/município/termo e assiste/baixa o trecho)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o assessor de comunicação (`communicator`) entra, vê **só** a vertical `/campanha/comunicacao`, busca falas por termo (com e sem acento), tema, alcance, ano, fase, município citado e duração; vê o trecho destacado; abre o detalhe com o player já no ponto do trecho, transcrição clicável, sumário/contexto e ações de baixar/abrir fonte. `coordinator`/`candidate` também veem; `advisor`/`leader` não. Mobile utilizável; estados vazio, carregando e sem permissão.
|
||||
- **O que NÃO negociar:**
|
||||
- O `communicator` **não herda staff** (`isStaffCampaignRole` intocado); `leader` continua em lockdown; nada de permissão por município (comunicação é global).
|
||||
- URLs da intenção: home `/campanha/comunicacao`; acervo `/campanha/comunicacao/acervo`; detalhe `/campanha/comunicacao/acervo/[id]`.
|
||||
- VOD da Câmara nesta fase (sem espelho/S3), crédito CC BY 4.0 visível; sem editor de corte, sem busca semântica, sem exposição pública, sem segundo cadastro de pessoas.
|
||||
- Sem dashboard de vaidade nem % estadual absoluto; nenhuma PII de cidadão (dado parlamentar público — o guardrail é access, não Consent novo).
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- "Busca em endpoint `POST` no wrapper `campaignJsonMutationRoute`": a lista é leitura paginada server-rendered — o padrão do repo (apoiadores/pessoas/municípios) é URL state + RSC; reavaliado em D4.
|
||||
- "Módulo de access reexportado por `campaignAccess.ts`": o C153 já entregou `canReadSpeech`/`canUpdateSpeech`; o C154 **consome** e não mexe em `src/utilities/access` (evita acordar os e2e curados de RBAC e o fail-closed de risco à toa). O gate de página usa o predicado client-safe `canReadSpeechCatalog`.
|
||||
- "Precedente `home-search` (busca com `contains`)": o precedente certo de lista é `src/utilities/supporter/` + `CampaignListOmnibox`; `home-search` é uma busca de cliente, outra superfície.
|
||||
- "Sem player no repo" (verificado): não existe player; o único vídeo é um iframe YouTube. O C154 usa `<video>` nativo (D6) — sem dependência nova.
|
||||
- "0 discursos no `teqo_wt154`" (verificado): e2e/int criam os próprios fixtures (ownership estendido); a prova viva com conteúdo real exige `pnpm camara:import --limit` no worktree.
|
||||
- `excerptTMs` é o offset em **ms na sessão**; `segment.startSeconds` é relativo ao **MP4 do trecho** — nunca misturar (seek = `startSeconds`).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[login] --> B{/campanha}
|
||||
B -- communicator --> C[/campanha/comunicacao]
|
||||
C --> D[/campanha/comunicacao/acervo]
|
||||
D --> E[speechListUrl<br/>parse/canonicaliza]
|
||||
E --> F[buildSpeechListWhere]
|
||||
F --> G[(speech.find<br/>facetas + searchText like<br/>ou keywords contains)]
|
||||
G --> H[(speechSegment.find<br/>speech in pageIds)]
|
||||
H --> I[speechHighlight<br/>offsets normalizado para original]
|
||||
I --> J[cards com trecho destacado]
|
||||
J --> K[/campanha/comunicacao/acervo/:id]
|
||||
K --> L[(loadSpeechDetailPageData<br/>speech + segmentos)]
|
||||
L --> M[video nativo<br/>seek no startSeconds]
|
||||
M --> N[transcricao clicavel<br/>baixar / abrir fonte]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C por decisão (D1–D6 abaixo).
|
||||
**Recomendação:** D1–D6 na ordem em que travam as demais — D1/D2 travam o schema e a query, D3/D5 travam o acesso e as rotas, D4 trava o contrato de URL, D6 trava o detalhe.
|
||||
**Rejeitadas:** registradas dentro de cada decisão (formato obrigatório da decision-quality).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
#### D1 — Busca e paginação por discurso: denormalizar `speech.searchText` (C)
|
||||
|
||||
- **Opções:** A) scan paginado de `speechSegment` por `searchText contains` + dedupe de discurso em memória + `find` de `speech` por IDs com facetas + segunda query de segmentos da página | B) SQL cru drizzle com `DISTINCT ON (speech_id)` + join das tabelas de faceta | C) coluna `searchText` no `speech` (concatenação normalizada dos segmentos, GIN trgm) e **uma** query de `speech` com busca + facetas, paginando por discurso; segunda query só para os segmentos da página.
|
||||
- **Recomendação:** C — a contagem e a paginação passam a ser do **discurso** (é o que a UI mostra: "N falas encontradas"), sem dedupe entre páginas e sem perder resultado; busca e facetas caem na mesma `where` do Payload, com `overrideAccess: false` e o access do C153 aplicado de verdade. O custo é uma migration mecânica (coluna + GIN + backfill) e uma linha a mais no `upsertSpeechBundle`, que já é o dono dos segmentos. O `Where` é montado assim (explícito, em `speechListFilters.ts`):
|
||||
```ts
|
||||
where = {
|
||||
and: [
|
||||
...facetFilters, // year in, topics in, scopes in, phase in, mentionedMunicipalities in, OR dos buckets de duração
|
||||
...(q
|
||||
? [
|
||||
{
|
||||
or: [
|
||||
{ searchText: { like: normalizeForSearch(q) } }, // segmentos concatenados, palavras em qualquer ordem, sem acento
|
||||
{ keywords: { contains: q } }, // keyword oficial, termo exato (ILIKE)
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
```
|
||||
A paginação é feita **por discurso** com `payload.find({ collection: 'speech', where, sort: '-speechAt', page, limit: 25, user, overrideAccess: false })` — o `totalDocs` já é o número de falas, e nenhum segmento extra pode "vazar" para a página 2. Os trechos vêm de uma segunda query enxuta: `payload.find({ collection: 'speechSegment', where: { speech: { in: pageIds } }, sort: 'order', pagination: false, select: { speech, order, startSeconds, endSeconds, text, searchText }, user, overrideAccess: false })`, escolhendo em memória o primeiro segmento cujo `searchText` casa TODOS os termos normalizados (senão o primeiro segmento; senão o `summary`).
|
||||
- **Limite de escala:** a coluna concatena ~10–50 KB por discurso; a 4 mil discursos (backfill C155) o GIN trgm fica na casa das centenas de MB e `%term%` de 2 chars vira seq scan (trigram exige ≥3). **Gatilho de revisitação:** catálogo > ~5.000 discursos, p95 da busca > 300ms, índice > ~1 GB, ou `q` de 1–2 chars virar caso de uso real — aí B (drizzle `DISTINCT ON`) ou `tsvector` passam a valer.
|
||||
- **Rejeitadas:**
|
||||
- A porque o count/paginação por discurso exigiria varrer **todos** os segmentos que casam a cada página (um termo curto casa o catálogo inteiro), o dedupe entre lotes reintroduz o bug clássico de resultado que some na página 2, e ainda obrigaria um `speech in [até 4.000 ids]` em toda query.
|
||||
- B porque bypassa o access do Payload (a camada fail-closed), duplica a montagem de joins (`speech_topics`, `speech_scopes`, `speech_rels`) e vira um twin do query layer para um ganho que C já entrega no tamanho atual.
|
||||
- A variante "buscar também no `officialTranscript`" fica fora por ora: a intenção fixa busca nos segmentos, e o transcript oficial não tem offset para posicionar o player. Revisitar se a taxa de discurso sem segmentos do C155 justificar.
|
||||
|
||||
#### D2 — Ciclo de vida do `speech.searchText` (quem mantém)
|
||||
|
||||
- **Opções:** A) `upsertSpeechBundle` (C153) calcula e grava no mesmo write quando `segments !== undefined`; migration faz o backfill | B) hook `afterChange`/`afterDelete` em `speechSegment` recomputa o pai | C) trigger de banco.
|
||||
- **Recomendação:** A — o bundle é o único escritor de segmentos em produção (o segmento é `admin.hidden`), roda em transação e já normaliza cada segmento; `searchText` do discurso = `normalizeForSearch(segments.map(text).join(' '))`, gravado no mesmo `create`/`update`. `segments: undefined` **preserva** o valor armazenado (mesma semântica dos segmentos no C153). A migration backfill usa `string_agg(search_text, ' ' ORDER BY "order")` sobre `speech_segment`.
|
||||
- **Rejeitadas:** B porque cada insert de segmento dispararia um update do pai — O(n²) no import (7.590 segmentos na prova C153) — e escreveria dentro de hook com access; C porque esconde a regra fora do fluxo de migrations e dificulta o teste local.
|
||||
- **Drift conhecido:** escrita de segmento fora do bundle (teste, script futuro) não atualiza o pai; o contrato é "o import é o dono" e um int test pina o bundle. Gatilho: segundo escritor de segmentos em produção.
|
||||
|
||||
#### D3 — Gate do papel: novo valor `'speechCatalog'` no `requireCampaignPageActor`
|
||||
|
||||
- **Opções:** A) novo gate `'speechCatalog'` no `CampaignPageGate`, usando `canReadSpeechCatalog` (já existe, client-safe) | B) reusar `gate: 'staff'` e promover o communicator a staff | C) sem gate de página, só o access da collection.
|
||||
- **Recomendação:** A — a página redireciona cedo (leader → `/campanha/meus-contatos`; advisor/outros → `/campanha`), e o `denyRedirect` ganha o atalho `communicator → /campanha/comunicacao` para o login não fazer o communicator escalar pelo home de staff. O predicado é o **mesmo** do access (`canReadSpeechCatalog`), então página e collection não divergem; `isStaffCampaignRole` fica intocado (lockdown C153).
|
||||
- **Rejeitadas:** B porque abriria todas as áreas de staff ao communicator (viola "vê só a vertical"); C porque a página renderizaria erro/vazio silencioso em vez do redirect canônico, e o `codebaseConventions.unit.spec.ts` já pina que o prologue de página vem do `requireCampaignPageActor`.
|
||||
|
||||
#### D4 — Contrato de filtros: params canônicos na URL (A)
|
||||
|
||||
- **Opções:** A) params multi-select canônicos `q · year · topic · scope · phase · municipality · duration · page`, sobre `resolveListUrl`/`buildListHref` | B) POST em `campaignJsonMutationRoute` (hipótese da intenção) | C) estado só de cliente.
|
||||
- **Recomendação:** A — é o contrato B18/B127 já usado por apoiadores/pessoas/municípios: URL compartilhável/bookmarkável, redirect de params desconhecidos e clamp de página grátis (`resolveListUrl`), pending/loading e omnibox existentes. Enums exaustivos (`topic` 18, `scope` 3, `duration` 4) passam por `parseExhaustiveEnumParam` (selecionar todos = sem filtro); `year` (4 dígitos), `phase` (texto ≤80) e `municipality` (id > 0) são data-driven e validados estruturalmente. Buckets de duração: `curta` < 120s · `media` 120–300s · `longa` > 300s · `sem_duracao` (nulo) — registrado. Sem param de sort neste item: ordem fixa `-speechAt` (o "Mais recentes ▾" do draft fica como rótulo estático). Opções que dependem de DB (ano, fase, município citado) vêm do servidor; tema/alcance/duração são constantes client-safe de `speechFacets.ts`. `q` de 1 char é ignorado (mesma ideia de `isContactSearchQueryReady`).
|
||||
- **Rejeitadas:** B porque o wrapper de mutação é para escrita com erro seguro; uma leitura paginada por POST perde deep-link, voltar/avançar e o pending compartilhado do RSC; C porque perde tudo isso e a URL canônica que o resto do produto usa.
|
||||
- **Nota de semântica:** a busca sobre segmentos usa `like` com o termo normalizado — todas as palavras em qualquer ordem, sem acento (uma palavra é o caso trivial); keyword oficial é termo exato case-insensitive (`contains` → ILIKE) e sensível a acento; a busca sem acento continua coberta pelo `searchText` dos segmentos. A copy do campo deve dizer "termo exato" para keyword.
|
||||
|
||||
#### D5 — Rotas e home da vertical: redirect (A)
|
||||
|
||||
- **Opções:** A) `/campanha/comunicacao` redireciona para `/campanha/comunicacao/acervo`; nav aponta para a home | B) landing com um card "Acervo" | C) nav aponta direto para o acervo e a home fica vazia.
|
||||
- **Recomendação:** A — a intenção fixa as três rotas; o job do assessor **é** a busca (a primeira cena do gate de forma é o acervo), então uma landing de um card é um clique morto. O redirect preserva a identidade da vertical (nav ativa em tudo que é `/campanha/comunicacao/...`), dá casa para itens futuros e evita duas URLs para a mesma página. O login cai em `/campanha`, então `(app)/page.tsx` redireciona o communicator para a vertical (e o `denyRedirect` do D3 usa o mesmo alvo).
|
||||
- **Rejeitadas:** B porque inventa tela fora do gate de forma e adiciona um clique; C porque deixa a rota da intenção sem conteúdo.
|
||||
|
||||
#### D6 — Player e seek: `<video>` nativo (A)
|
||||
|
||||
- **Opções:** A) `<video controls preload="metadata">` nativo com seek via `#t=<startSeconds>` no `src` + `currentTime` em `loadedmetadata`, sem dependência | B) react-player/hls.js | C) iframe do YouTube da sessão.
|
||||
- **Recomendação:** A — o VOD é MP4 direto (`vodPlaybackUrl`, o MP4 do trecho), os segmentos são relativos a esse MP4 (seek = `startSeconds`) e o repo não tem player; o controle nativo é acessível, mobile-friendly e de bundle zero. Sem autoplay (bloqueado pelos browsers): o usuário dá play e começa no fragmento. Um único client component (`SpeechDetailPlayer`) segura o ref do `<video>` e a lista de segmentos clicáveis (click → `currentTime` + `play()`). Fallbacks: sem `vodPlaybackUrl` → bloco "VOD indisponível" + Abrir fonte; sem segmentos → transcrição oficial, sem seek.
|
||||
- **Rejeitadas:** B porque adiciona dependência e superfície para tocar MP4 progressivo; C porque a sessão do YouTube não tem o offset do trecho confiável e o guardrail pede o VOD da Câmara nesta fase (o YouTube continua como "Abrir fonte", sem seek).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/collections/Speech.ts`** (editar o dono): campo `searchText` (textarea, `defaultValue: ''`, `admin.readOnly` + description "Concatenação normalizada dos segmentos; mantida pelo import") — sem `index` no campo (GIN é hand-written). **Reavaliado na execução:** `required: true` foi descartado porque o Payload rejeita string vazia em campo required e uma fala sem segmentos legitimamente não tem texto de busca; o campo fica opcional com default `''` (coluna nullable, default `''`).
|
||||
- **`src/utilities/speech/speechImport.ts`** (editar o dono): quando `bundle.segments !== undefined`, calcular `segmentSearchText` (join normalizado dos segmentos, reusando o mesmo `normalizeForSearch` de cada create) e incluir no `data` do create/update do discurso; `segments: undefined` não toca no valor. Um write, transação preservada.
|
||||
- **Migration:** `pnpm migrate:create add_speech_search_text` (coluna) + hand-written `add_speech_search_text_trgm_index` com backfill `string_agg(search_text, ' ' ORDER BY "order")` e `CREATE INDEX "speech_search_text_trgm_idx" ON "speech" USING gin ("search_text" gin_trgm_ops)`; registrar em `src/migrations/index.ts`; `down` dropa só o índice (backfill é dado, documentado). Seguir a skill `payload-migrations`.
|
||||
- **`src/lib/speechHighlight.ts`** (novo, puro): `findHighlightRanges(text, query)` via mapa char-a-char normalizado→range original (NFD remove combining marks e muda comprimento; lowercase pode expandir; espaços colapsam) e `buildHighlightedExcerpt(text, query, { radius })` → `{ parts, truncatedStart, truncatedEnd }`; múltiplos termos (a query é dividida em palavras normalizadas, todas destacadas). Rejeitadas: `text.toLowerCase().indexOf` (quebra com acento/NFD), regex sobre o original (mesmo problema), destacar o texto normalizado (perde a grafia original), highlight só no cliente (o RSC já renderiza).
|
||||
- **`src/utilities/speech/speechListUrl.ts`** (novo, puro/client-safe): `SpeechListState`, nomes de param, `parseSpeechListParams` (page estrito, `q` normalizado, enums exaustivos, year/phase/municipality estruturais), `buildSpeechListSearchParams`, `buildSpeechListHref`, `resolveSpeechListUrl`, `buildSpeechFiltersKey`, buckets/labels de duração, `toggleSpeechFilterValue`.
|
||||
- **`src/utilities/speech/speechListFilters.ts`** (novo, puro): `buildSpeechListWhere(state)` exatamente como D1 (a montagem do `Where` fica em UM lugar, testável por unit/int).
|
||||
- **`src/utilities/speech/speechPageData.ts`** (novo, `server-only`): `loadSpeechAcervoPageData` (find do discurso + segmentos da página + opções server-side), `loadSpeechDetailPageData` (discurso `depth: 0` + segmentos por `order`), `SpeechNotFoundError` via `createEntityNotFoundError('Speech', 'Fala não encontrada.')`, `loadSpeechFilterOptions` (anos/fases do catálogo; municípios citados → `loadMunicipalityLabelsByIds` — o bypass justificado de `loadNamesByIds`, pois os ids vêm de discursos que o ator já podia ler).
|
||||
- **`src/utilities/speech/speechViewModels.ts`** (novo, puro): `formatSpeechAt` (fatiar o wall-clock `speechAt` sem `new Date` — sem TZ, DST-safe), `formatSpeechDuration`, `toSpeechListItemViewModel` (trecho escolhido, `matchKind: segment|keyword|fallback`, chips, URLs), `toSpeechDetailViewModel` (segmentos, municípios, `seekSeconds`).
|
||||
- **`src/utilities/speech/speechOmnibox.ts`** (novo, puro/client-safe): chips ativos, seeds de sugestão (temas/alcances/durações + anos/fases/municípios server-side) e ações (toggle/remove/clear) reusando `src/lib/campaignListOmnibox.ts`.
|
||||
- **`src/components/campaign/speech/SpeechAcervoFilters.tsx`** (novo, client): `<form role="search">` com `campaignListOmniboxFormClassName` + `CampaignListOmnibox` (busca/chips/pending) e a régua de facetas com `CampaignHeaderFilterPopover` (multi-select, `closeOnChoose: false`), no padrão de `MunicipalityHeaderFilter`.
|
||||
- **`src/components/campaign/speech/SpeechResultList.tsx` / `SpeechResultCard.tsx`** (novos, server): cards com trecho destacado, chips (temas/alcances preenchidos; keywords outline) e ações "Assistir no trecho" (`?t=&q=`), "Baixar" (`vodDownloadUrl`) e "Abrir fonte" (`officialTextUrl` ?? `youtubeUrl`).
|
||||
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`** (novo, client): `<video>` + transcrição ASR clicável (timestamps), highlight do `q`, segmento ativo, `data-slot="speech-player"`/`data-start-seconds`; transcrição oficial em `<details>` (leitura/citação).
|
||||
- **`src/components/campaign/shared/CampaignHeaderFilterPopover.tsx`** (editar o dono): prop opcional de trigger-chip (`triggerVariant?: 'icon' | 'chip'` + `triggerLabel`), default `'icon'` — os call sites existentes ficam byte-idênticos.
|
||||
- **`src/utilities/campaignPageActor.ts`** (editar): gate `'speechCatalog'` com `canReadSpeechCatalog` + atalho do communicator no `denyRedirect`.
|
||||
- **`src/components/campaign/shell/nav.ts`** (editar): `communicatorNav` com o item "Comunicação" (ícone `MegaphoneIcon`), retornado para o communicator; o mesmo item no `staffNav` filtrado por `isUnrestrictedCampaignRole` (coordinator/candidate veem; advisor não). `getCampaignBottomNav`/`getCampaignOverflowNav` intocados: communicator não é staff → sem bottom nav (usa a sidebar/Sheet no mobile, como o leader); coordinator/candidate ganham "Comunicação" no drawer "Mais".
|
||||
- **`src/lib/campaignPageChrome.ts`** (editar): entradas `comunicacao` ("Comunicação") e `acervo` ("Acervo de falas" + subtitle "Discursos do Deputado Jorge Solla na Câmara"); path rules para `/campanha/comunicacao` e `/campanha/comunicacao/acervo`; detalhe → `null` (chrome por rota via `SetCampaignPageChrome`).
|
||||
- **`src/lib/campaignPaths.ts`** (editar): `CAMPAIGN_COMMUNICATION_HOME` e `CAMPAIGN_COMMUNICATION_ACERVO`.
|
||||
- **`src/app/(campaign)/campanha/(app)/page.tsx`** (editar): `if (user.role === 'communicator') redirect(CAMPAIGN_COMMUNICATION_HOME)` (o login cai em `/campanha`).
|
||||
- **Pages** (`src/app/(campaign)/campanha/(app)/comunicacao/`): `page.tsx` (redirect para o acervo), `acervo/page.tsx` (lista; `metadata = campaignPageMetadataFromCatalog('acervo')`; `requireCampaignPageActor({ gate: 'speechCatalog' })`; `redirectHref` do `resolveSpeechListUrl`), `acervo/loading.tsx` (skeleton no molde de apoiadores), `acervo/[id]/page.tsx` (`generateMetadata` com catch → "Fala"; loader; `notFound()` em `SpeechNotFoundError`; `SetCampaignPageChrome`), `acervo/[id]/not-found.tsx` (Empty + voltar ao acervo, molde apoiadores).
|
||||
- **Access / Consent:** sem mudança de access (reusa `canReadSpeech`/`canUpdateSpeech` do C153 e o predicado `canReadSpeechCatalog`); sem Consent novo (dado parlamentar público CC BY 4.0). Leituras sempre `user` + `overrideAccess: false`; o único bypass é `loadMunicipalityLabelsByIds` (doutrina `loadNamesByIds`).
|
||||
- **UI:** Impeccable C — três cenas do gate de forma (lista, detalhe, mobile ~390px) + vazio; shells reusados (`CampaignPageShell`, `CampaignListOmnibox` sticky, `CampaignHeaderFilterPopover`, `CampaignListEmptyState/Results/Pending/Footer`, `CampaignPageChrome`); `<mark>` para destaque e o acento do app (não o amber literal do draft); ciclo shape→craft→critique→polish; sem token novo. O card do draft mapeia: data/tipo/duração/presidiu → excerpt com `<mark>` → chips → ações; o vazio sugere temas/alcances + limpar; mobile empilha e a régua de filtros rola na horizontal.
|
||||
- **Testes:** unit `speechHighlight`, `speechListUrl`, `speechListFilters`, `speechOmnibox`; extensões `campaignNav.unit.spec.ts` (communicator deixa de ser vazio) e `campaignPageChrome.unit.spec.ts`; int `speechAcervo.int.spec.ts` (busca com/sem acento, keyword exata, facetas, paginação por discurso, RBAC allow/deny) + extensão `speechImport.int.spec.ts` (bundle mantém `speech.searchText`; `segments: undefined` preserva); e2e `campaignSpeechAcervo.e2e.spec.ts` + entry no manifest + ownership de `speech`/`speechSegment` nos fixtures (e2e e int).
|
||||
- **Manifest e2e:** entry `prefixes: ['src/app/(campaign)/campanha/(app)/comunicacao', 'src/components/campaign/speech', 'src/utilities/speech', 'src/lib/speech']`, `specs: ['campaignSpeechAcervo']` (o `src/lib/speech` cobre `speechSearch`/`speechFacets`/`speechHighlight`); `e2eAffectedManifest.unit.spec.ts` pina existência em disco e os curated/risk ficam intocados.
|
||||
- **Changelog:** `docs/changelog/2026-09-13-c154.md`.
|
||||
|
||||
### Ajustes descobertos na execução (com dono no repo)
|
||||
|
||||
- **Sollinha fora da vertical (débito C153 resolvido).** O C153 deferiu com gatilho C154 os chips de líder que o fallback não-staff devolvia ao communicator. O C154 esconde as superfícies do assistente para esse papel: `canUseCampaignAssistant(role)` em `src/lib/campaignRoles.ts`, early-return em `CampaignAISidebarShell` e o botão do header condicionado em `CampaignDesktopHeader` (role threaded pelo layout). Coordinator/candidate/leader intocados.
|
||||
- **Keywords oficiais: parser do C153 corrigido no dono.** A API da Câmara separa grupos por linha e keywords individuais por vírgula (dado vivo: `Governo federal,reconstrução,Política pública`); `parseOfficialKeywords` (`scripts/lib/camaraSpeeches.mjs`) só quebrava linha e o card exibia um chip gigante. Agora quebra `[\r\n,]+`; unit test atualizado com o shape real. C155 importa produção já com o parser certo.
|
||||
- **Densidade do card:** temas/alcances/keywords são capados (3/2/3) com um chip `+N`; o detalhe lista tudo. Os botões de ação do card e os gatilhos de filtro em chip usam `min-h-11`/`min-h-10` (alvo de toque).
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- **Forma escolhida:** lista de cards de fala (data/hora · tipo · duração · quem presidia → trecho com destaque → chips de tema/alcance/keywords → ações), contagem simples "N falas encontradas" e paginação; chips de faceta como rótulos, sem número/percentual. Por quê: a unidade é uma fala com texto para **ler e escolher**; o dado que desbloqueia a decisão é o trecho (onde está a citação), não um agregado.
|
||||
- **Rejeitadas:** tabela (não deixa ler o trecho, que é o ponto), dashboard/gráficos (vaidade; a intenção proíbe % absoluto/KPI), timeline (sem necessidade de produto e sem ganho sobre a lista por data).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server** (≈1 dia): migration + campo `searchText` + bundle; `speechListUrl`/`speechListFilters`/`speechPageData`; gate `'speechCatalog'`, nav, paths, chrome e redirect do home; acervo renderizando o resultado cru. **Prova vertical cedo:** `tests/int/speechAcervo.int.spec.ts` cria 2 discursos (um com 3 segmentos que casam, outro com 1), prova busca sem acento (`q=saude` acha "saúde"), keyword exata (`contains`), facetas, `totalDocs` = discursos (não segmentos) e RBAC (communicator/coordinator/candidate leem; advisor/leader não). Migrations aplicadas em `teqo_wt154` e `_test` antes dos int.
|
||||
2. **UI** (≈1–1,25 dia): `SpeechAcervoFilters` (omnibox + popovers), cards com highlight, `SpeechDetailPlayer` (seek/transcrição/baixar/abrir fonte, VOD indisponível, sem segmentos), vazio/loading/not-found, mobile; rodada Impeccable de critique/polish sobre as três cenas do draft.
|
||||
3. **Gates** (≈0,5 dia): `pnpm gate:fast`; `pnpm test:e2e:affected` (prefixo shell + entry nova); `pnpm knip`; `pnpm generate:types` sem diff; `pnpm migrate`/`migrate:status` limpos; smoke vivo com `pnpm camara:import --limit 3` no `teqo_wt154` + um communicator de teste navegando (busca "saude"/"SUS", filtro por município, seek no detalhe, download); `pnpm push`; changelog.
|
||||
|
||||
**Quota total:** ~2,5–2,75 dias, dentro do appetite; a migration é a única peça "de schema" e é mecânica.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Player de verdade:** hls.js/react-player, controles customizados, PiP, autoplay, playlist. Corte: `<video>` nativo.
|
||||
- **Espelho/CORS/proxy de mídia:** baixar o MP4 para o S3, resolver Range/CORS do VOD. Corte: link direto + "Abrir fonte" (é o guardrail do C155).
|
||||
- **Corte/edição no browser, fila de render, coleções/favoritos, download em lote.** Corte: player + download por trecho.
|
||||
- **Busca semântica/IA, ranking, frase exata, `tsvector`/`unaccent`.** Corte: `searchText like` normalizado + keyword exata.
|
||||
- **Busca no `officialTranscript`** (discurso sem segmento): revisitar com dados do C155.
|
||||
- **Keyword sem acento (normalizada):** exigiria campo/denormalização extra; revisitar se virar atrito real.
|
||||
- **Sort param, saved filters (B18), sugestões de busca, destaque alinhado oficial↔ASR.** Fora.
|
||||
- **YouTube com seek** para discurso sem VOD: só "Abrir fonte" sem offset.
|
||||
- **Permissão por município na comunicação / home rica da vertical.** Fora (anti-goals).
|
||||
- **Mexer em `src/utilities/access` ou `campaignAccess.ts`:** desnecessário (C153 já cobre) e acordaria o fail-closed de risco.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Seek/CORS/Range/autoplay do MP4:** controle nativo + `#t=` + `currentTime` em `loadedmetadata`; sem autoplay; fallback "Abrir fonte"; e2e não testa reprodução (só o HTML/atributos) — se o VOD não suportar Range, o seek pode degradar, e o detalhe continua útil (transcrição + download).
|
||||
- **VOD indisponível/nulo:** UI condicional ("VOD indisponível" + fonte), sem crash; `vodDownloadUrl ?? vodPlaybackUrl`.
|
||||
- **Discurso sem segmentos (transcrição oficial só):** detalhe mostra a oficial e não oferece seek; lista mostra `summary` como preview; sem quebra.
|
||||
- **Busca de frase que atravessa segmentos:** a concatenação acha o discurso, mas nenhum segmento isolado casa — o card cai no fallback (summary/primeiro segmento) sem highlight; documentado.
|
||||
- **Performance/índice:** GIN trgm em `speech.search_text`; `%q%` de 2 chars = seq scan aceitável no tamanho atual; limite/gatilho registrados em D1; migration roda antes do build (OPS66) e o `CREATE INDEX` não-concurrente é irrelevante no volume atual.
|
||||
- **Drift do `speech.searchText`:** só o bundle escreve; int test pina; escrito fora do bundle é caminho não suportado.
|
||||
- **E2E de RBAC:** spec novo cobre communicator/coordinator (200) e advisor/leader (redirect canônico), na rota do acervo e do detalhe; helper `assertCampaignRedirect` generaliza o `assertLeaderRedirect` existente (owner em `campaignHttpTest.ts`).
|
||||
- **Dados nos testes:** e2e/int criam `speech`/`speechSegment` com marcadores únicos (`runID`) e ownership estendido (segmento antes de discurso no cleanup); não dependem do import real.
|
||||
- **Pins que mudam:** `campaignNav.unit.spec.ts` (communicator deixa de ser `[]`) e `campaignPageChrome.unit.spec.ts`; o manifest ganha entry nova e o spec existe em disco (pinned).
|
||||
- **Regressão da régua de filtros compartilhada:** a extensão do `CampaignHeaderFilterPopover` é opt-in; rodar os e2e de municípios/pessoas via `test:e2e:affected` (prefixo do shell) antes do push.
|
||||
- **Wildcards em `q` (`%`/`_`):** `contains`/`like` já tratam o valor como literal param (sem SQL injection), mas `%` vira curinga — mesmo comportamento dos filtros existentes; aceito e documentado.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: `communicator` vê só a vertical; coordinator/candidate veem; advisor/leader não; busca com e sem acento sobre segmentos + keyword exata; filtros (ano/tema/alcance/fase/município citado/duração); destaque do termo; detalhe com player no trecho, transcrição clicável, sumário/contexto, baixar e abrir fonte; estados vazio/carregando/sem permissão; mobile; crédito CC BY 4.0.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: migration via `migrate:create` + índice/backfill hand-written registrado; `src/utilities/speech/` (sem módulo top-level novo); `server-only` no loader; identificadores em inglês; `overrideAccess: false` nas leituras com `user`; `communicator` fora de `isStaffCampaignRole`; sem `Consent` novo.
|
||||
- [ ] Testes de domínio previstos: unit (highlight, URL/filtros, omnibox, nav, chrome) e int (busca acento/keyword/facetas/paginação/RBAC + bundle do `searchText`); e2e novo + manifest; `e2eAffectedManifest.unit.spec.ts` verde.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
1. **Decisões caras têm rejeitadas?** 5/5 — D1 (busca/paginação), D2 (ciclo do `searchText`), D3 (gate), D4 (contrato de URL), D5 (rotas/home) e D6 (player) estão no formato Opções|Recomendação|Rejeitadas, com o porquê de cada rejeitada; as baratas (buckets de duração, ícones, copy) ficam registradas em uma linha.
|
||||
2. **Cabe no appetite?** 5/5 — ~2,5–2,75 dias contra 2–3 herdados; a única peça de schema é mecânica e o player é `<video>` nativo sem dependência.
|
||||
3. **Rabbit holes nomeados?** 5/5 — player "de verdade", espelho/CORS de mídia, corte no browser, busca semântica/tsvector, busca no transcript oficial, keyword normalizada, YouTube com seek, home rica, mexer no access do C153.
|
||||
4. **Depth check reusa?** 5/5 — `resolveListUrl`/`buildListHref`, `CampaignListOmnibox` + `campaignListOmnibox`, `CampaignHeaderFilterPopover` (extensão opt-in), `CampaignListEmptyState/Results/Pending/Footer`, `requireCampaignPageActor`, `createEntityNotFoundError`, `loadNamesByIds`, `withPayloadTransaction` e o bundle do C153; nenhum shell ou query layer novo.
|
||||
5. **Intenção permanece satisfeita?** 5/5 — o outcome não foi reescrito: as três rotas da intenção existem, o communicator não herda staff, a busca é substring com/sem acento + keyword exata, e a única adição de schema (`speech.searchText`) serve exatamente ao "N falas encontradas" com paginação por discurso que a UI pede.
|
||||
|
||||
## Débitos (triage pós-simplify)
|
||||
|
||||
Nenhum achado dos dois revisores virou Issue nova (nenhum `expensive_lock` com score ≥4). O que ficou:
|
||||
|
||||
**Deferido com gatilho:**
|
||||
|
||||
- **`loadSpeechFilterOptions` varre o catálogo inteiro a cada página** (`pagination: false` sobre ano/fase/municípios citados): trivial nos 234 discursos atuais e o custo é o mesmo em qualquer página. Gatilho: o mesmo do D1 (catálogo > ~5.000 discursos ou p95 do acervo > 300ms) — aí a leitura vira `DISTINCT`/agregação SQL ou cache das opções, respeitando o access do papel.
|
||||
|
||||
**Descartado:**
|
||||
|
||||
- **Chave React ausente** (`LeadershipSortableHead`/`AdvisorsPage`, componentes compartilhados): pré-existente em `main`, fora da superfície do C154; warning só em dev (CI/prod verde). Bug separado, não deste lote.
|
||||
- **`pickMatchingSegment` re-normaliza `segment.text`** em vez de usar o `searchText` do segmento: micro-CPU sem impacto; o select do campo foi removido no /simplify como plumbing morto. Reabrir só com profiling.
|
||||
- **`redirectHref ?? canonicalUrl.redirectHref`** no loader do acervo: redundante, mas espelha o dono (`supporterPageData`); consistência vale mais que a linha.
|
||||
- **Fixture e2e duplica o contrato do bundle** (`searchText` hardcoded): deliberado para não importar `server-only` no spec; o int test pina o bundle.
|
||||
|
||||
**Já resolvido no simplify (não reabrir):**
|
||||
|
||||
- `speechOmnibox` unificado (`mutateFacet`).
|
||||
- `parseOfficialKeywords` corrigido no dono (vírgula + linha) — C155 importa certo.
|
||||
- Sollinha escondido do `communicator` (débito C153 fechado).
|
||||
|
|
@ -109,14 +109,17 @@ export const speechContentHash = (summary, transcript) =>
|
|||
.slice(0, 12)
|
||||
|
||||
/**
|
||||
* Official keywords come as a newline-separated string; keep each keyword raw.
|
||||
* Official keywords: the API separates keyword GROUPS with newlines and the
|
||||
* individual keywords inside a group with commas (live data:
|
||||
* `Governo federal,reconstrução,Política pública`). Both are separators; no
|
||||
* official keyword contains a comma.
|
||||
*
|
||||
* @param {unknown} raw
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const parseOfficialKeywords = (raw) =>
|
||||
String(raw ?? '')
|
||||
.split(/[\r\n]+/)
|
||||
.split(/[\r\n,]+/)
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
|
|
|
|||
|
|
@ -274,6 +274,20 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
],
|
||||
specs: ['campaignPermissionProfileHttp'],
|
||||
},
|
||||
{
|
||||
// C154 — the communication vertical: the acervo search/filters/detail and
|
||||
// the `speechCatalog` role gate. `src/lib/speech*` carries the pure
|
||||
// search/highlight modules the RSC list renders with.
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/comunicacao`,
|
||||
'src/components/campaign/speech',
|
||||
'src/utilities/speech',
|
||||
'src/lib/speech',
|
||||
// The role predicates drive the vertical gate and the assistant surfaces.
|
||||
'src/lib/campaignRoles',
|
||||
],
|
||||
specs: ['campaignSpeechAcervo'],
|
||||
},
|
||||
{
|
||||
prefixes: [
|
||||
'src/components/campaign/advisor',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
|
||||
export default function SpeechNotFoundPage() {
|
||||
return (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Fala não encontrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
O registro pode ter sido removido ou você não tem acesso a ele.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Voltar ao acervo</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon, DownloadIcon, ExternalLinkIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { SpeechDetailPlayer } from '@/components/campaign/speech/SpeechDetailPlayer'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadSpeechDetailPageData, SpeechNotFoundError } from '@/utilities/speech/speechPageData'
|
||||
import { parseSpeechSeekSeconds } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
type SpeechDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
const backHref = (q: string | undefined): string =>
|
||||
q ? `${CAMPAIGN_COMMUNICATION_ACERVO}?q=${encodeURIComponent(q)}` : CAMPAIGN_COMMUNICATION_ACERVO
|
||||
|
||||
export async function generateMetadata({ params }: SpeechDetailPageProps) {
|
||||
const { id } = await params
|
||||
const speechId = strictDecimalInteger(id)
|
||||
if (!speechId) return campaignPageMetadata({ title: 'Fala' })
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
try {
|
||||
const view = await loadSpeechDetailPageData(payload, user, speechId)
|
||||
return campaignPageMetadata({ title: view.type ?? 'Fala', subtitle: view.speechAtLabel })
|
||||
} catch {
|
||||
return campaignPageMetadata({ title: 'Fala' })
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SpeechDetailPage({ params, searchParams }: SpeechDetailPageProps) {
|
||||
const [{ id }, query, user, payload] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
const speechId = strictDecimalInteger(id)
|
||||
if (!speechId) notFound()
|
||||
|
||||
const q = firstValue(query.q)
|
||||
const initialSeconds = parseSpeechSeekSeconds(firstValue(query.t))
|
||||
|
||||
const view = await loadSpeechDetailPageData(payload, user, speechId, q).catch((error) => {
|
||||
if (error instanceof SpeechNotFoundError) notFound()
|
||||
throw error
|
||||
})
|
||||
|
||||
const sourceUrl = view.officialTextUrl ?? view.youtubeUrl
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<SetCampaignPageChrome
|
||||
chrome={{ title: view.type ?? 'Fala', subtitle: view.speechAtLabel }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button asChild variant="ghost" className="-ml-2 min-h-10 self-start">
|
||||
<Link href={backHref(q)}>
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar ao acervo
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-medium">{view.type ?? 'Fala'}</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{view.speechAtLabel}</span>
|
||||
{view.phase ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{view.phase}</span>
|
||||
</>
|
||||
) : null}
|
||||
{view.durationLabel ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{view.durationLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
{view.presidingOfficer ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Presidiu: {view.presidingOfficer}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<SpeechDetailPlayer
|
||||
vodPlaybackUrl={view.vodPlaybackUrl}
|
||||
segments={view.segments}
|
||||
initialSeconds={initialSeconds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{view.vodDownloadUrl ? (
|
||||
<Button asChild className="min-h-10">
|
||||
<a href={view.vodDownloadUrl} target="_blank" rel="noreferrer">
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Baixar vídeo (MP4)
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<Button asChild variant="outline" className="min-h-10">
|
||||
<a href={sourceUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir fonte
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{view.officialTranscript ? (
|
||||
<details className="mt-5 rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Transcrição oficial (taquigrafia)
|
||||
</summary>
|
||||
<p className="mt-2 text-sm whitespace-pre-line text-muted-foreground">
|
||||
{view.officialTranscript}
|
||||
</p>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
{view.summary ? (
|
||||
<section>
|
||||
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Sumário oficial
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-foreground/90">{view.summary}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view.topics.length ? (
|
||||
<section>
|
||||
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">Temas</h2>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{view.topics.map((topic) => (
|
||||
<Badge key={topic.value} variant="secondary" className="font-normal">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view.scopes.length ? (
|
||||
<section>
|
||||
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">Alcance</h2>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{view.scopes.map((scope) => (
|
||||
<Badge key={scope.value} variant="secondary" className="font-normal">
|
||||
{scope.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view.municipalities.length ? (
|
||||
<section>
|
||||
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Municípios citados
|
||||
</h2>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{view.municipalities.map((municipality) => (
|
||||
<Badge key={municipality.id} variant="outline" className="font-normal">
|
||||
{municipality.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view.keywords.length ? (
|
||||
<section>
|
||||
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Palavras-chave oficiais
|
||||
</h2>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{view.keywords.map((keyword) => (
|
||||
<Badge
|
||||
key={keyword}
|
||||
variant="outline"
|
||||
className="font-normal text-muted-foreground"
|
||||
>
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="border-t pt-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fonte: Câmara dos Deputados · CC BY 4.0
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/** List-shaped skeleton for the speech acervo and its detail routes. */
|
||||
export default function SpeechAcervoLoading() {
|
||||
return (
|
||||
<CampaignPageShell aria-label="Carregando acervo de falas" aria-busy="true">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-5 w-80 max-w-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-24 rounded-full" />
|
||||
<Skeleton className="h-9 w-28 rounded-full" />
|
||||
<Skeleton className="h-9 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import config from '@payload-config'
|
||||
import { SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFilters'
|
||||
import { SpeechResultList } from '@/components/campaign/speech/SpeechResultList'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { buildSpeechFiltersKey, buildSpeechListHref } from '@/utilities/speech/speechListUrl'
|
||||
import { loadSpeechAcervoPageData } from '@/utilities/speech/speechPageData'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('acervo')
|
||||
|
||||
type SpeechAcervoPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPageProps) {
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'speechCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const data = await loadSpeechAcervoPageData(payload, user, searchParams)
|
||||
if (data.redirectHref) redirect(data.redirectHref)
|
||||
|
||||
const hasFilters = buildSpeechFiltersKey(data.state) !== ''
|
||||
|
||||
return (
|
||||
<CampaignPageShell aria-label="Acervo de falas">
|
||||
<CampaignListPendingBoundary>
|
||||
<SpeechAcervoFilters
|
||||
key={buildSpeechFiltersKey(data.state)}
|
||||
state={data.state}
|
||||
filterOptions={data.filterOptions}
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
{data.rows.length > 0 ? (
|
||||
<SpeechResultList rows={data.rows} />
|
||||
) : (
|
||||
<CampaignListEmptyState
|
||||
icon={SearchXIcon}
|
||||
title={
|
||||
data.state.q
|
||||
? `Nenhuma fala encontrada para "${data.state.q}"`
|
||||
: 'Nenhuma fala encontrada'
|
||||
}
|
||||
description={
|
||||
hasFilters
|
||||
? 'Tente outro termo, remova filtros ou busque por um tema.'
|
||||
: 'O acervo ainda não tem falas importadas.'
|
||||
}
|
||||
>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
</CampaignListEmptyState>
|
||||
)}
|
||||
|
||||
{data.rows.length > 0 ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={data.totalDocs}
|
||||
singular="fala encontrada"
|
||||
plural="falas encontradas"
|
||||
page={data.state.page}
|
||||
totalPages={data.totalPages}
|
||||
hrefForPage={(page) => buildSpeechListHref(data.state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
14
src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
Normal file
14
src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
||||
/**
|
||||
* C154 — the communication vertical home. The assessor's job IS the search, so
|
||||
* the vertical opens directly on the acervo; the route exists so the nav has a
|
||||
* stable destination and future vertical items have a home.
|
||||
*/
|
||||
export default async function CommunicationHomePage() {
|
||||
await requireCampaignPageActor({ gate: 'speechCatalog' })
|
||||
redirect(CAMPAIGN_COMMUNICATION_ACERVO)
|
||||
}
|
||||
|
|
@ -108,6 +108,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
<CampaignDesktopHeader
|
||||
role={user.role}
|
||||
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
||||
/>
|
||||
{/*
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import config from '@payload-config'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
|
|||
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
|
||||
import { UNCOVERED_MUNICIPALITIES_LIST_HREF } from '@/lib/campaignHomeActions'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { CAMPAIGN_COMMUNICATION_HOME } from '@/lib/campaignPaths'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -22,6 +24,10 @@ export const metadata = campaignPageMetadata(null)
|
|||
export default async function CampaignHomePage() {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
|
||||
|
||||
// C154 — the communicator's home is the communication vertical; the login
|
||||
// lands on `/campanha`, so this is where the redirect happens.
|
||||
if (user.role === 'communicator') redirect(CAMPAIGN_COMMUNICATION_HOME)
|
||||
|
||||
const staff = isStaffCampaignRole(user.role)
|
||||
|
||||
// C142 — the write scope filters the home actions for advisors.
|
||||
|
|
|
|||
|
|
@ -185,6 +185,22 @@ export const Speech: CollectionConfig = {
|
|||
description: 'Preservadas cruas; as facetas não as substituem.',
|
||||
},
|
||||
},
|
||||
{
|
||||
// C154 — normalized concatenation of the segment texts, so the acervo
|
||||
// search paginates by SPEECH (the unit the UI shows) with the Payload
|
||||
// access layer in the path. Kept in sync by `upsertSpeechBundle` (the
|
||||
// only writer of segments); the GIN trigram index is hand-written.
|
||||
name: 'searchText',
|
||||
type: 'textarea',
|
||||
label: 'Texto normalizado (busca)',
|
||||
// Optional on purpose: a speech without ASR segments legitimately has no
|
||||
// search text, and Payload's `required` rejects the empty string.
|
||||
defaultValue: '',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Concatenação normalizada dos segmentos (sem acentos); mantida pelo import.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'eventId',
|
||||
type: 'number',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { FunnelIcon, FunnelPlusIcon } from 'lucide-react'
|
||||
import { ChevronDownIcon, FunnelIcon, FunnelPlusIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
|
|
@ -29,6 +29,13 @@ type CampaignHeaderFilterPopoverProps = {
|
|||
id: string
|
||||
label: string
|
||||
optionRows: CampaignHeaderFilterRow[]
|
||||
/**
|
||||
* C154 — chip trigger for the speech acervo filter ruler ("Ano: 2026 ▾").
|
||||
* Default `'icon'` keeps every existing call site byte-identical.
|
||||
*/
|
||||
triggerVariant?: 'icon' | 'chip'
|
||||
/** Chip text; the caller folds the selection into it ("Ano: 2026"). */
|
||||
triggerLabel?: string
|
||||
}
|
||||
|
||||
const SEARCHABLE_OPTION_THRESHOLD = 8
|
||||
|
|
@ -70,6 +77,8 @@ export const CampaignHeaderFilterPopover = ({
|
|||
id,
|
||||
label,
|
||||
optionRows,
|
||||
triggerVariant = 'icon',
|
||||
triggerLabel,
|
||||
}: CampaignHeaderFilterPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -116,6 +125,10 @@ export const CampaignHeaderFilterPopover = ({
|
|||
if (closeOnChoose) setOpen(false)
|
||||
}
|
||||
|
||||
const triggerAriaLabel = active
|
||||
? `Filtrar ${label}: ativo. Alterar filtro`
|
||||
: `Filtrar por ${label}`
|
||||
|
||||
const renderRows = (rows: readonly CampaignHeaderFilterRow[]) =>
|
||||
rows.map((row) => <FilterRow key={row.value} row={row} onChoose={() => choose(row)} />)
|
||||
|
||||
|
|
@ -133,20 +146,37 @@ export const CampaignHeaderFilterPopover = ({
|
|||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex size-11 shrink-0 items-center justify-center rounded-md hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-label={active ? `Filtrar ${label}: ativo. Alterar filtro` : `Filtrar por ${label}`}
|
||||
>
|
||||
{active ? (
|
||||
<FunnelPlusIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
{triggerVariant === 'chip' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-full border px-3 text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active
|
||||
? 'border-foreground/25 bg-muted text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
)}
|
||||
aria-label={triggerAriaLabel}
|
||||
>
|
||||
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<span className="max-w-40 truncate">{triggerLabel ?? label}</span>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex size-11 shrink-0 items-center justify-center rounded-md hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-label={triggerAriaLabel}
|
||||
>
|
||||
{active ? (
|
||||
<FunnelPlusIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2">
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@ import { CampaignAIHeaderButton } from '@/components/campaign/shell/ai/CampaignA
|
|||
import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
|
||||
import { SidebarTrigger } from '@/components/ui/Sidebar'
|
||||
import { canUseCampaignAssistant, type CampaignRole } from '@/lib/campaignRoles'
|
||||
|
||||
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => {
|
||||
export const CampaignDesktopHeader = ({
|
||||
notificationBell,
|
||||
role,
|
||||
}: {
|
||||
notificationBell?: ReactNode
|
||||
role: CampaignRole
|
||||
}) => {
|
||||
const headerActions = useCampaignHeaderActions()
|
||||
|
||||
return (
|
||||
|
|
@ -19,7 +26,7 @@ export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?:
|
|||
<Fragment key={id}>{node}</Fragment>
|
||||
))}
|
||||
{notificationBell ?? null}
|
||||
<CampaignAIHeaderButton />
|
||||
{canUseCampaignAssistant(role) ? <CampaignAIHeaderButton /> : null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from 'react-resizable-panels'
|
||||
|
||||
import type { CampaignRole } from '@/lib/campaignRoles'
|
||||
import { canUseCampaignAssistant } from '@/lib/campaignRoles'
|
||||
import {
|
||||
CHAT_MIN_PX,
|
||||
getSavedChatWidthPx,
|
||||
|
|
@ -38,6 +39,11 @@ export const CampaignAISidebarShell = ({
|
|||
}) => {
|
||||
const panelRef = usePanelRef()
|
||||
|
||||
// C154 — the communication assessor has no Sollinha: the assistant's tools
|
||||
// are campaign-scoped and the vertical is her whole surface (the FAB, the
|
||||
// drawer and the desktop header button all live inside these surfaces).
|
||||
if (!canUseCampaignAssistant(role)) return <>{children}</>
|
||||
|
||||
return (
|
||||
<CampaignAISidebarProvider role={role} panelRef={panelRef}>
|
||||
<CampaignAIFab />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
LayoutDashboardIcon,
|
||||
MapIcon,
|
||||
MapPinIcon,
|
||||
MegaphoneIcon,
|
||||
UserCogIcon,
|
||||
Users2Icon,
|
||||
UsersIcon,
|
||||
|
|
@ -20,11 +21,16 @@ import {
|
|||
|
||||
import {
|
||||
CAMPAIGN_AGENDA_HOME,
|
||||
CAMPAIGN_COMMUNICATION_HOME,
|
||||
CAMPAIGN_CONTACTS_HOME,
|
||||
CAMPAIGN_UPDATES_HREF,
|
||||
LEADER_CONTACTS_HOME,
|
||||
} from '@/lib/campaignPaths'
|
||||
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
|
||||
import {
|
||||
canReadSpeechCatalog,
|
||||
isStaffCampaignRole,
|
||||
isUnrestrictedCampaignRole,
|
||||
} from '@/lib/campaignRoles'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
|
||||
|
||||
|
|
@ -61,6 +67,7 @@ const staffNav: CampaignNavItem[] = [
|
|||
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
|
||||
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },
|
||||
{ title: 'Assessores', href: '/campanha/assessores', icon: UserCogIcon },
|
||||
{ title: 'Comunicação', href: CAMPAIGN_COMMUNICATION_HOME, icon: MegaphoneIcon },
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
@ -82,15 +89,24 @@ const leaderNav: CampaignNavItem[] = [
|
|||
{ title: 'Meus contatos', href: LEADER_CONTACTS_HOME, icon: UsersIcon },
|
||||
]
|
||||
|
||||
/**
|
||||
* C154 — the communication assessor is not staff: the sidebar shows only the
|
||||
* vertical (plus the perfil/logout footer). Coordinator/candidate also reach
|
||||
* the vertical, through the staff nav below.
|
||||
*/
|
||||
const communicatorNav: CampaignNavItem[] = [
|
||||
{ title: 'Comunicação', href: CAMPAIGN_COMMUNICATION_HOME, icon: MegaphoneIcon },
|
||||
]
|
||||
|
||||
export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] => {
|
||||
if (role === 'leader') return leaderNav
|
||||
// C153 — the communication assessor is not staff: no staff destinations.
|
||||
// The catalog vertical (C154) adds its own nav items here.
|
||||
if (role === 'communicator') return []
|
||||
if (role === 'communicator') return communicatorNav
|
||||
|
||||
return staffNav.filter((item) => {
|
||||
if (item.href === '/campanha/apoiadores') return canAccessSupporterArea(role)
|
||||
if (item.href === '/campanha/assessores') return isUnrestrictedCampaignRole(role)
|
||||
// C154 — advisor has no speech catalog access; coordinator/candidate do.
|
||||
if (item.href === CAMPAIGN_COMMUNICATION_HOME) return canReadSpeechCatalog(role)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
|
|
|||
252
src/components/campaign/speech/SpeechAcervoFilters.tsx
Normal file
252
src/components/campaign/speech/SpeechAcervoFilters.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useOptimistic, useState } from 'react'
|
||||
|
||||
import {
|
||||
CampaignHeaderFilterPopover,
|
||||
type CampaignHeaderFilterRow,
|
||||
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
import {
|
||||
buildSpeechListHref,
|
||||
SPEECH_DURATION_BUCKETS,
|
||||
speechDurationLabels,
|
||||
speechScopeLabels,
|
||||
speechTopicLabels,
|
||||
type SpeechFilterOptions,
|
||||
type SpeechListState,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
import {
|
||||
applySpeechOmniboxSuggestion,
|
||||
buildSpeechOmniboxChips,
|
||||
buildSpeechOmniboxSuggestionSeeds,
|
||||
clearSpeechOmnibox,
|
||||
filterSpeechOmniboxSuggestions,
|
||||
removeSpeechOmniboxChip,
|
||||
type SpeechOmniboxAction,
|
||||
} from '@/utilities/speech/speechOmnibox'
|
||||
|
||||
const topicOptions = SPEECH_TOPICS.map(({ value, label }) => ({ value, label }))
|
||||
const scopeOptions = SPEECH_SCOPES.map(({ value, label }) => ({ value, label }))
|
||||
const durationOptions = SPEECH_DURATION_BUCKETS.map(({ value, label }) => ({ value, label }))
|
||||
|
||||
type FacetKey = 'years' | 'topics' | 'scopes' | 'phases' | 'municipalities' | 'durations'
|
||||
|
||||
type SpeechFacet = {
|
||||
id: string
|
||||
label: string
|
||||
/** Prefix of the omnibox suggestion id (`${prefix}:${value}`). */
|
||||
prefix: string
|
||||
stateKey: FacetKey
|
||||
options: readonly { value: string; label: string }[]
|
||||
selected: readonly string[]
|
||||
labels?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const selectedTriggerLabel = (
|
||||
base: string,
|
||||
values: readonly string[],
|
||||
labels?: Readonly<Record<string, string>>,
|
||||
): string => {
|
||||
if (values.length === 0) return base
|
||||
if (values.length === 1) return `${base}: ${labels?.[values[0]!] ?? values[0]}`
|
||||
return `${base}: ${values.length}`
|
||||
}
|
||||
|
||||
export const SpeechAcervoFilters = ({
|
||||
state,
|
||||
filterOptions,
|
||||
}: {
|
||||
state: SpeechListState
|
||||
filterOptions: SpeechFilterOptions
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => buildSpeechListHref(next, 1),
|
||||
})
|
||||
const [query, setQuery] = useState('')
|
||||
const [viewState, setOptimisticState] = useOptimistic(state)
|
||||
|
||||
const municipalityLabelsById = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const option of filterOptions.municipalities) {
|
||||
const id = Number(option.value)
|
||||
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
|
||||
}
|
||||
return map
|
||||
}, [filterOptions.municipalities])
|
||||
|
||||
const chips = useMemo(
|
||||
() => buildSpeechOmniboxChips({ state: viewState, municipalityLabelsById }),
|
||||
[viewState, municipalityLabelsById],
|
||||
)
|
||||
|
||||
const suggestionSeeds = useMemo(
|
||||
() =>
|
||||
buildSpeechOmniboxSuggestionSeeds({
|
||||
years: filterOptions.years,
|
||||
phases: filterOptions.phases,
|
||||
municipalityOptions: filterOptions.municipalities,
|
||||
}),
|
||||
[filterOptions],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => filterSpeechOmniboxSuggestions(suggestionSeeds, query),
|
||||
[suggestionSeeds, query],
|
||||
)
|
||||
|
||||
const runAction = (action: SpeechOmniboxAction) => {
|
||||
if (action.kind === 'clear') {
|
||||
setQuery('')
|
||||
navigate(action.state)
|
||||
return
|
||||
}
|
||||
navigate(action.state)
|
||||
}
|
||||
|
||||
const facetRows = (
|
||||
prefix: string,
|
||||
options: readonly { value: string; label: string }[],
|
||||
selected: readonly string[],
|
||||
): CampaignHeaderFilterRow[] =>
|
||||
options.map((option) => {
|
||||
const next = applySpeechOmniboxSuggestion({
|
||||
state: viewState,
|
||||
suggestionId: `${prefix}:${option.value}`,
|
||||
}).state
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: buildSpeechListHref(next, 1),
|
||||
selected: selected.includes(option.value),
|
||||
checkbox: true,
|
||||
onChoose: () => setOptimisticState(next),
|
||||
}
|
||||
})
|
||||
|
||||
const clearFacet = (facet: FacetKey) => {
|
||||
const next: SpeechListState = { ...viewState, page: 1, [facet]: undefined }
|
||||
return { href: buildSpeechListHref(next, 1), onChoose: () => setOptimisticState(next) }
|
||||
}
|
||||
|
||||
const years = (viewState.years ?? []).map(String)
|
||||
const topics = viewState.topics ?? []
|
||||
const scopes = viewState.scopes ?? []
|
||||
const phases = viewState.phases ?? []
|
||||
const municipalities = (viewState.municipalities ?? []).map(String)
|
||||
const durations = viewState.durations ?? []
|
||||
|
||||
const facets: SpeechFacet[] = [
|
||||
{
|
||||
id: 'speech-filter-year',
|
||||
label: 'Ano',
|
||||
prefix: 'year',
|
||||
stateKey: 'years',
|
||||
options: filterOptions.years.map((year) => ({ value: String(year), label: String(year) })),
|
||||
selected: years,
|
||||
},
|
||||
{
|
||||
id: 'speech-filter-topic',
|
||||
label: 'Tema',
|
||||
prefix: 'topic',
|
||||
stateKey: 'topics',
|
||||
options: topicOptions,
|
||||
selected: topics,
|
||||
labels: speechTopicLabels,
|
||||
},
|
||||
{
|
||||
id: 'speech-filter-scope',
|
||||
label: 'Alcance',
|
||||
prefix: 'scope',
|
||||
stateKey: 'scopes',
|
||||
options: scopeOptions,
|
||||
selected: scopes,
|
||||
labels: speechScopeLabels,
|
||||
},
|
||||
{
|
||||
id: 'speech-filter-phase',
|
||||
label: 'Fase',
|
||||
prefix: 'phase',
|
||||
stateKey: 'phases',
|
||||
options: filterOptions.phases.map((phase) => ({ value: phase, label: phase })),
|
||||
selected: phases,
|
||||
},
|
||||
{
|
||||
id: 'speech-filter-municipality',
|
||||
label: 'Município',
|
||||
prefix: 'municipality',
|
||||
stateKey: 'municipalities',
|
||||
options: filterOptions.municipalities,
|
||||
selected: municipalities,
|
||||
labels: Object.fromEntries(municipalityLabelsById),
|
||||
},
|
||||
{
|
||||
id: 'speech-filter-duration',
|
||||
label: 'Duração',
|
||||
prefix: 'duration',
|
||||
stateKey: 'durations',
|
||||
options: durationOptions,
|
||||
selected: durations,
|
||||
labels: speechDurationLabels,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<CampaignListOmnibox
|
||||
id="speech-omnibox"
|
||||
label="Buscar no acervo de falas"
|
||||
placeholder="Busque por assunto, tema, município ou palavra-chave…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applySpeechOmniboxSuggestion({ state: viewState, suggestionId }))
|
||||
}}
|
||||
onCommitQuery={(text) => {
|
||||
runAction(applySpeechOmniboxSuggestion({ state: viewState, suggestionId: `q:${text}` }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeSpeechOmniboxChip({ state: viewState, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearSpeechOmnibox(viewState))
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Filtros do acervo"
|
||||
className="mt-2 flex gap-2 overflow-x-auto pb-1 md:flex-wrap md:overflow-visible"
|
||||
>
|
||||
{facets.map((facet) => (
|
||||
<CampaignHeaderFilterPopover
|
||||
key={facet.id}
|
||||
id={facet.id}
|
||||
label={facet.label}
|
||||
triggerVariant="chip"
|
||||
triggerLabel={selectedTriggerLabel(facet.label, facet.selected, facet.labels)}
|
||||
active={facet.selected.length > 0}
|
||||
closeOnChoose={false}
|
||||
optionRows={facetRows(facet.prefix, facet.options, facet.selected)}
|
||||
clear={facet.selected.length ? clearFacet(facet.stateKey) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
119
src/components/campaign/speech/SpeechDetailPlayer.tsx
Normal file
119
src/components/campaign/speech/SpeechDetailPlayer.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
'use client'
|
||||
|
||||
import { FilmIcon } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
/**
|
||||
* C154 — native `<video>` plus the clickable ASR transcript. The VOD is the
|
||||
* Câmara excerpt MP4 and the segment `startSeconds` are relative to it, so a
|
||||
* click is a plain `currentTime` seek. No autoplay (browsers block it): the
|
||||
* user presses play and the video starts at the requested fragment.
|
||||
*/
|
||||
export const SpeechDetailPlayer = ({
|
||||
vodPlaybackUrl,
|
||||
segments,
|
||||
initialSeconds,
|
||||
}: {
|
||||
vodPlaybackUrl: string | null
|
||||
segments: readonly SpeechDetailSegmentViewModel[]
|
||||
initialSeconds: number | null
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [activeStart, setActiveStart] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || initialSeconds === null || initialSeconds <= 0) return
|
||||
|
||||
const seek = () => {
|
||||
video.currentTime = initialSeconds
|
||||
}
|
||||
if (video.readyState >= 1) {
|
||||
seek()
|
||||
return
|
||||
}
|
||||
video.addEventListener('loadedmetadata', seek, { once: true })
|
||||
return () => video.removeEventListener('loadedmetadata', seek)
|
||||
}, [initialSeconds, vodPlaybackUrl])
|
||||
|
||||
const seekTo = (seconds: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.currentTime = seconds
|
||||
void video.play().catch(() => {})
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const current = video.currentTime
|
||||
const active = segments.find(
|
||||
(segment) => current >= segment.startSeconds && current < segment.endSeconds,
|
||||
)
|
||||
setActiveStart(active?.startSeconds ?? null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="speech-player">
|
||||
{vodPlaybackUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={vodPlaybackUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
) : (
|
||||
<div className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border bg-muted/40 text-muted-foreground">
|
||||
<FilmIcon className="size-8" aria-hidden="true" />
|
||||
<p className="text-sm">VOD indisponível para esta fala.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{segments.length > 0 ? (
|
||||
<section className="mt-5" aria-label="Transcrição">
|
||||
<p className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Transcrição (clique para posicionar)
|
||||
</p>
|
||||
<ol className="mt-2 space-y-0.5">
|
||||
{segments.map((segment) => {
|
||||
const active = activeStart === segment.startSeconds
|
||||
return (
|
||||
<li key={segment.startSeconds}>
|
||||
<button
|
||||
type="button"
|
||||
data-start-seconds={segment.startSeconds}
|
||||
onClick={() => seekTo(segment.startSeconds)}
|
||||
className={cn(
|
||||
'flex w-full gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-12 shrink-0 pt-0.5 text-xs tabular-nums',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{segment.startLabel}
|
||||
</span>
|
||||
<span className="text-sm leading-relaxed text-foreground/90">
|
||||
<SpeechHighlightParts parts={segment.parts} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/campaign/speech/SpeechHighlightParts.tsx
Normal file
19
src/components/campaign/speech/SpeechHighlightParts.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { SpeechHighlightPart } from '@/lib/speechHighlight'
|
||||
|
||||
/**
|
||||
* C154 — renders a speech text split into highlighted/normal parts (the card
|
||||
* excerpt and the detail transcript share the same marking).
|
||||
*/
|
||||
export const SpeechHighlightParts = ({ parts }: { parts: readonly SpeechHighlightPart[] }) => (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.highlighted ? (
|
||||
<mark key={index} className="rounded-sm bg-amber-200/70 px-0.5 text-foreground">
|
||||
{part.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{part.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)
|
||||
134
src/components/campaign/speech/SpeechResultCard.tsx
Normal file
134
src/components/campaign/speech/SpeechResultCard.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { DownloadIcon, ExternalLinkIcon, PlayIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
|
||||
import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
/**
|
||||
* The LLM classifies many topics per speech; the card shows the leading few so
|
||||
* the excerpt keeps the scan path (the detail lists all of them).
|
||||
*/
|
||||
const MAX_TOPIC_CHIPS = 3
|
||||
const MAX_SCOPE_CHIPS = 2
|
||||
const MAX_KEYWORD_CHIPS = 3
|
||||
|
||||
const MetaSeparator = () => <span aria-hidden="true">·</span>
|
||||
|
||||
const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
|
||||
if (excerpt.parts.length === 0) return null
|
||||
|
||||
return (
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
{excerpt.truncatedStart ? '… ' : null}
|
||||
<SpeechHighlightParts parts={excerpt.parts} />
|
||||
{excerpt.truncatedEnd ? ' …' : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionLink = ({
|
||||
href,
|
||||
children,
|
||||
variant,
|
||||
}: {
|
||||
href: string
|
||||
children: ReactNode
|
||||
variant: 'default' | 'outline' | 'ghost'
|
||||
}) => (
|
||||
<Button asChild variant={variant} className="min-h-11">
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }) => {
|
||||
const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
|
||||
const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
|
||||
const visibleKeywords = speech.keywords.slice(0, MAX_KEYWORD_CHIPS)
|
||||
const hiddenChips =
|
||||
speech.topics.length -
|
||||
visibleTopics.length +
|
||||
(speech.scopes.length - visibleScopes.length) +
|
||||
(speech.keywords.length - visibleKeywords.length)
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{speech.speechAtLabel}</span>
|
||||
{speech.type ? (
|
||||
<>
|
||||
<MetaSeparator />
|
||||
<span>{speech.type}</span>
|
||||
</>
|
||||
) : null}
|
||||
{speech.durationLabel ? (
|
||||
<>
|
||||
<MetaSeparator />
|
||||
<span>{speech.durationLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
{speech.presidingOfficer ? (
|
||||
<>
|
||||
<MetaSeparator />
|
||||
<span>Presidiu: {speech.presidingOfficer}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<SpeechExcerpt excerpt={speech.excerpt} />
|
||||
</div>
|
||||
|
||||
{speech.topics.length || speech.scopes.length || speech.keywords.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{visibleTopics.map((topic) => (
|
||||
<Badge key={topic.value} variant="secondary" className="font-normal">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleScopes.map((scope) => (
|
||||
<Badge key={scope.value} variant="secondary" className="font-normal">
|
||||
{scope.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleKeywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
{hiddenChips > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{hiddenChips}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href={speech.watchHref}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
|
||||
</Link>
|
||||
</Button>
|
||||
{speech.downloadUrl ? (
|
||||
<ActionLink href={speech.downloadUrl} variant="outline">
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Baixar
|
||||
</ActionLink>
|
||||
) : null}
|
||||
{speech.sourceUrl ? (
|
||||
<ActionLink href={speech.sourceUrl} variant="ghost">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir fonte
|
||||
</ActionLink>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
10
src/components/campaign/speech/SpeechResultList.tsx
Normal file
10
src/components/campaign/speech/SpeechResultList.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { SpeechResultCard } from '@/components/campaign/speech/SpeechResultCard'
|
||||
import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
export const SpeechResultList = ({ rows }: { rows: readonly SpeechListItemViewModel[] }) => (
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((row) => (
|
||||
<SpeechResultCard key={row.id} speech={row} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -113,6 +113,13 @@ export const campaignPageChromeCatalog = {
|
|||
title: 'Meu perfil',
|
||||
subtitle: 'Gerencie sua foto, sua senha e a entrada por biometria.',
|
||||
},
|
||||
comunicacao: {
|
||||
title: 'Comunicação',
|
||||
},
|
||||
acervo: {
|
||||
title: 'Acervo de falas',
|
||||
subtitle: 'Discursos do Deputado Jorge Solla na Câmara.',
|
||||
},
|
||||
} as const satisfies Record<string, CampaignPageChrome | null>
|
||||
|
||||
const resolveCatalogEntry = (
|
||||
|
|
@ -271,6 +278,18 @@ const pathRules: PathRule[] = [
|
|||
match: (pathname) => pathname === '/campanha/perfil',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.perfil),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/comunicacao/acervo',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.acervo),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/comunicacao\/acervo\/[^/]+$/.test(pathname),
|
||||
resolve: () => null,
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/comunicacao',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.comunicacao),
|
||||
},
|
||||
]
|
||||
|
||||
export const resolveCampaignPageChrome = (
|
||||
|
|
|
|||
|
|
@ -20,4 +20,9 @@ export const LEADER_CONTACTS_HOME = '/campanha/meus-contatos' as const
|
|||
|
||||
export const CAMPAIGN_PROFILE_HOME = '/campanha/perfil' as const
|
||||
|
||||
/** Communication vertical (C154) — home redirects to the acervo. */
|
||||
export const CAMPAIGN_COMMUNICATION_HOME = '/campanha/comunicacao' as const
|
||||
|
||||
export const CAMPAIGN_COMMUNICATION_ACERVO = '/campanha/comunicacao/acervo' as const
|
||||
|
||||
export const CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes' as const
|
||||
|
|
|
|||
|
|
@ -24,3 +24,11 @@ export const isUnrestrictedCampaignRole = (role: CampaignRole): boolean =>
|
|||
*/
|
||||
export const canReadSpeechCatalog = (role: CampaignRole): boolean =>
|
||||
role === 'communicator' || isUnrestrictedCampaignRole(role)
|
||||
|
||||
/**
|
||||
* Sollinha (AI assistant) surfaces. The communication assessor does not use
|
||||
* the campaign assistant — its tools are campaign-scoped and the vertical is
|
||||
* her whole job (C153 debt, resolved by C154). Leaders keep the safe
|
||||
* link/meta chips.
|
||||
*/
|
||||
export const canUseCampaignAssistant = (role: CampaignRole): boolean => role !== 'communicator'
|
||||
|
|
|
|||
169
src/lib/speechHighlight.ts
Normal file
169
src/lib/speechHighlight.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
|
||||
/**
|
||||
* C154 — term highlighting for the speech acervo. The search normalizes text
|
||||
* (NFD strips accents, lowercase) so "saude" matches "saúde"; highlighting has
|
||||
* to map those matches back to the ORIGINAL spelling, which means the
|
||||
* normalized string cannot be searched with `indexOf` on the source text.
|
||||
*
|
||||
* `buildNormalizedIndex` normalizes character by character while recording, for
|
||||
* each normalized character, the source index it came from — so a range in the
|
||||
* normalized space converts to a source range. Pure and unit-tested.
|
||||
*/
|
||||
|
||||
export type SpeechHighlightPart = {
|
||||
text: string
|
||||
highlighted: boolean
|
||||
}
|
||||
|
||||
export type SpeechHighlightedExcerpt = {
|
||||
parts: SpeechHighlightPart[]
|
||||
truncatedStart: boolean
|
||||
truncatedEnd: boolean
|
||||
}
|
||||
|
||||
export type SpeechHighlightRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type NormalizedIndex = {
|
||||
normalized: string
|
||||
/** `sourceIndex[i]` is the original text index that produced `normalized[i]`. */
|
||||
sourceIndex: number[]
|
||||
}
|
||||
|
||||
const buildNormalizedIndex = (text: string): NormalizedIndex => {
|
||||
const chars: string[] = []
|
||||
const sourceIndex: number[] = []
|
||||
let pendingSpace = false
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index]!
|
||||
if (/\s/.test(char)) {
|
||||
if (chars.length > 0) pendingSpace = true
|
||||
continue
|
||||
}
|
||||
const piece = normalizeForSearch(char)
|
||||
if (piece === '') continue
|
||||
if (pendingSpace) {
|
||||
chars.push(' ')
|
||||
sourceIndex.push(index)
|
||||
pendingSpace = false
|
||||
}
|
||||
for (const pieceChar of piece) {
|
||||
chars.push(pieceChar)
|
||||
sourceIndex.push(index)
|
||||
}
|
||||
}
|
||||
|
||||
return { normalized: chars.join(''), sourceIndex }
|
||||
}
|
||||
|
||||
/** Normalized search terms (words) of the query, in order, duplicates kept out. */
|
||||
export const speechSearchTerms = (query: string): string[] => [
|
||||
...new Set(
|
||||
normalizeForSearch(query)
|
||||
.split(' ')
|
||||
.filter((term) => term.length > 0),
|
||||
),
|
||||
]
|
||||
|
||||
/** True when every query term occurs somewhere in the text (accent-insensitive). */
|
||||
export const matchesSearchTerms = (text: string, query: string): boolean => {
|
||||
const terms = speechSearchTerms(query)
|
||||
if (terms.length === 0) return false
|
||||
const { normalized } = buildNormalizedIndex(text)
|
||||
return terms.every((term) => normalized.includes(term))
|
||||
}
|
||||
|
||||
/** Every occurrence of every term, mapped back to source offsets and merged. */
|
||||
export const findHighlightRanges = (text: string, query: string): SpeechHighlightRange[] => {
|
||||
const terms = speechSearchTerms(query)
|
||||
if (terms.length === 0 || text.length === 0) return []
|
||||
|
||||
const { normalized, sourceIndex } = buildNormalizedIndex(text)
|
||||
if (normalized.length === 0) return []
|
||||
|
||||
const ranges: SpeechHighlightRange[] = []
|
||||
for (const term of terms) {
|
||||
let from = 0
|
||||
while (from <= normalized.length - term.length) {
|
||||
const at = normalized.indexOf(term, from)
|
||||
if (at === -1) break
|
||||
ranges.push({
|
||||
start: sourceIndex[at]!,
|
||||
end: sourceIndex[at + term.length - 1]! + 1,
|
||||
})
|
||||
// Overlapping occurrences ("ana" in "banana") are found one step at a time.
|
||||
from = at + 1
|
||||
}
|
||||
}
|
||||
|
||||
ranges.sort((left, right) => left.start - right.start || left.end - right.end)
|
||||
const merged: SpeechHighlightRange[] = []
|
||||
for (const range of ranges) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && range.start <= last.end) {
|
||||
if (range.end > last.end) last.end = range.end
|
||||
continue
|
||||
}
|
||||
merged.push({ ...range })
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
const partsFromRanges = (
|
||||
text: string,
|
||||
ranges: readonly SpeechHighlightRange[],
|
||||
): SpeechHighlightPart[] => {
|
||||
const parts: SpeechHighlightPart[] = []
|
||||
let cursor = 0
|
||||
for (const range of ranges) {
|
||||
if (range.start > cursor) {
|
||||
parts.push({ text: text.slice(cursor, range.start), highlighted: false })
|
||||
}
|
||||
parts.push({ text: text.slice(range.start, range.end), highlighted: true })
|
||||
cursor = Math.max(cursor, range.end)
|
||||
}
|
||||
if (cursor < text.length) parts.push({ text: text.slice(cursor), highlighted: false })
|
||||
return parts
|
||||
}
|
||||
|
||||
/** All matches of the query highlighted, no windowing — the detail transcript. */
|
||||
export const splitHighlightedParts = (text: string, query: string): SpeechHighlightPart[] =>
|
||||
partsFromRanges(text, findHighlightRanges(text, query))
|
||||
|
||||
/**
|
||||
* A readable window around the first match (or the text start when there is
|
||||
* none), with the matched terms highlighted and truncation flags for the
|
||||
* ellipses the card renders.
|
||||
*/
|
||||
export const buildHighlightedExcerpt = (
|
||||
text: string,
|
||||
query: string,
|
||||
{ radius = 160 }: { radius?: number } = {},
|
||||
): SpeechHighlightedExcerpt => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return { parts: [], truncatedStart: false, truncatedEnd: false }
|
||||
|
||||
const ranges = findHighlightRanges(trimmed, query)
|
||||
const focus = ranges[0]
|
||||
const windowStart = focus ? Math.max(0, focus.start - radius) : 0
|
||||
const windowEnd = focus
|
||||
? Math.min(trimmed.length, focus.end + radius)
|
||||
: Math.min(trimmed.length, radius * 2)
|
||||
|
||||
const windowed = ranges
|
||||
.filter((range) => range.end > windowStart && range.start < windowEnd)
|
||||
.map((range) => ({
|
||||
start: Math.max(0, range.start - windowStart),
|
||||
end: Math.min(windowEnd - windowStart, range.end - windowStart),
|
||||
}))
|
||||
|
||||
return {
|
||||
parts: partsFromRanges(trimmed.slice(windowStart, windowEnd), windowed),
|
||||
truncatedStart: windowStart > 0,
|
||||
truncatedEnd: windowEnd < trimmed.length,
|
||||
}
|
||||
}
|
||||
15103
src/migrations/20260913_145820_add_speech_search_text.json
Normal file
15103
src/migrations/20260913_145820_add_speech_search_text.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260913_145820_add_speech_search_text.ts
Normal file
11
src/migrations/20260913_145820_add_speech_search_text.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "speech" ADD COLUMN "search_text" varchar DEFAULT '';`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "speech" DROP COLUMN "search_text";`)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* C154 — backfill the speech-level `search_text` (normalized concatenation of
|
||||
* the segments, already normalized in `speech_segment.search_text`) and index
|
||||
* it with GIN trigram, so the acervo search paginates by speech with one
|
||||
* `ILIKE '%term%'` per query. Hand-written (data + index, not a field change);
|
||||
* `upsertSpeechBundle` keeps the column in sync from now on.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
UPDATE "speech"
|
||||
SET "search_text" = COALESCE("segments"."search_text", '')
|
||||
FROM (
|
||||
SELECT "speech_id", string_agg("search_text", ' ' ORDER BY "order") AS "search_text"
|
||||
FROM "speech_segment"
|
||||
GROUP BY "speech_id"
|
||||
) AS "segments"
|
||||
WHERE "speech"."id" = "segments"."speech_id";`)
|
||||
await db.execute(
|
||||
sql`CREATE INDEX IF NOT EXISTS "speech_search_text_trgm_idx" ON "speech" USING gin ("search_text" gin_trgm_ops);`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`DROP INDEX IF EXISTS "speech_search_text_trgm_idx";`)
|
||||
// The backfill is data, not schema: `down` drops only the index. The column
|
||||
// itself is owned by the previous migration.
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ import * as migration_20260912_052226_add_google_calendar_oauth_connection from
|
|||
import * as migration_20260912_224719_add_activity_public_event from './20260912_224719_add_activity_public_event';
|
||||
import * as migration_20260913_001112_add_speech_catalog from './20260913_001112_add_speech_catalog';
|
||||
import * as migration_20260913_001200_add_speech_segment_trgm_index from './20260913_001200_add_speech_segment_trgm_index';
|
||||
import * as migration_20260913_150000_backfill_speech_search_text_trgm_index from './20260913_150000_backfill_speech_search_text_trgm_index';
|
||||
import * as migration_20260913_145820_add_speech_search_text from './20260913_145820_add_speech_search_text';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -413,11 +415,21 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260913_001112_add_speech_catalog.up,
|
||||
down: migration_20260913_001112_add_speech_catalog.down,
|
||||
name: '20260913_001112_add_speech_catalog'
|
||||
name: '20260913_001112_add_speech_catalog',
|
||||
},
|
||||
{
|
||||
up: migration_20260913_001200_add_speech_segment_trgm_index.up,
|
||||
down: migration_20260913_001200_add_speech_segment_trgm_index.down,
|
||||
name: '20260913_001200_add_speech_segment_trgm_index'
|
||||
name: '20260913_001200_add_speech_segment_trgm_index',
|
||||
},
|
||||
{
|
||||
up: migration_20260913_145820_add_speech_search_text.up,
|
||||
down: migration_20260913_145820_add_speech_search_text.down,
|
||||
name: '20260913_145820_add_speech_search_text'
|
||||
},
|
||||
{
|
||||
up: migration_20260913_150000_backfill_speech_search_text_trgm_index.up,
|
||||
down: migration_20260913_150000_backfill_speech_search_text_trgm_index.down,
|
||||
name: '20260913_150000_backfill_speech_search_text_trgm_index',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -941,6 +941,10 @@ export interface Speech {
|
|||
* Preservadas cruas; as facetas não as substituem.
|
||||
*/
|
||||
keywords?: string[] | null;
|
||||
/**
|
||||
* Concatenação normalizada dos segmentos (sem acentos); mantida pelo import.
|
||||
*/
|
||||
searchText?: string | null;
|
||||
eventId?: number | null;
|
||||
eventType?: string | null;
|
||||
eventStartAt?: string | null;
|
||||
|
|
@ -2081,6 +2085,7 @@ export interface SpeechSelect<T extends boolean = true> {
|
|||
officialTranscript?: T;
|
||||
officialTextUrl?: T;
|
||||
keywords?: T;
|
||||
searchText?: T;
|
||||
eventId?: T;
|
||||
eventType?: T;
|
||||
eventStartAt?: T;
|
||||
|
|
|
|||
|
|
@ -2,15 +2,19 @@ import 'server-only'
|
|||
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
|
||||
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
|
||||
import { CAMPAIGN_COMMUNICATION_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
|
||||
import {
|
||||
canReadSpeechCatalog,
|
||||
isStaffCampaignRole,
|
||||
isUnrestrictedCampaignRole,
|
||||
} from '@/lib/campaignRoles'
|
||||
import { advisorEditingAccess } from '@/utilities/access/shared'
|
||||
import { getCampaignUser, getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
|
||||
|
||||
/** Exactly the non-null actor `getCampaignUser` proves (keeps its `email` refinement). */
|
||||
export type CampaignPageActor = NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
|
||||
|
||||
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader' | 'writable'
|
||||
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader' | 'writable' | 'speechCatalog'
|
||||
|
||||
export { LEADER_CONTACTS_HOME }
|
||||
|
||||
|
|
@ -28,7 +32,10 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
|
|||
* - 'noLeader' → leader goes to `/campanha/meus-contatos` (B43, C139);
|
||||
* - 'writable' → advisor with Edição `somente_leitura` goes to `/campanha`
|
||||
* (C142 — write destinations must not be offered to a
|
||||
* read-only advisor; the server already rejects the write).
|
||||
* read-only advisor; the server already rejects the write);
|
||||
* - 'speechCatalog'→ advisor/leader lose the communication vertical (C154);
|
||||
* communicator/coordinator/candidate pass, the same
|
||||
* predicate the collection access uses.
|
||||
*
|
||||
* A custom `redirectTo` overrides the gate's default target. `withAvatar`
|
||||
* loads the actor through `getCampaignUserWithAvatar` (avatar populated at
|
||||
|
|
@ -53,9 +60,18 @@ export const requireCampaignPageActor = async (
|
|||
if (!user) redirect('/campanha/login')
|
||||
|
||||
const { gate, redirectTo } = options
|
||||
const denyRedirect = redirectTo ?? (user.role === 'leader' ? LEADER_CONTACTS_HOME : '/campanha')
|
||||
// C154 — a denied communicator belongs in the vertical, not in the staff
|
||||
// home (which itself redirects there, but a direct redirect avoids a hop).
|
||||
const roleHome: Partial<Record<typeof user.role, string>> = {
|
||||
leader: LEADER_CONTACTS_HOME,
|
||||
communicator: CAMPAIGN_COMMUNICATION_HOME,
|
||||
}
|
||||
const denyRedirect = redirectTo ?? roleHome[user.role] ?? '/campanha'
|
||||
|
||||
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
|
||||
// C154 — the speech catalog vertical: communicator/coordinator/candidate,
|
||||
// the same predicate the collection access uses.
|
||||
if (gate === 'speechCatalog' && !canReadSpeechCatalog(user.role)) redirect(denyRedirect)
|
||||
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect)
|
||||
if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect)
|
||||
if (gate === 'writable' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ type SpeechWriteData = RequiredDataFromCollectionSlug<'speech'>
|
|||
|
||||
const metadataData = (
|
||||
bundle: SpeechImportBundle,
|
||||
): Omit<SpeechWriteData, 'sourceKey' | 'classifiedBy'> => ({
|
||||
): Omit<SpeechWriteData, 'sourceKey' | 'classifiedBy' | 'searchText'> => ({
|
||||
speechAt: bundle.speechAt,
|
||||
year: bundle.year,
|
||||
legislature: bundle.legislature,
|
||||
|
|
@ -160,6 +160,14 @@ export const upsertSpeechBundle = async (
|
|||
const current = found.docs[0]
|
||||
const manualFacetsPreserved = current?.classifiedBy === 'manual'
|
||||
|
||||
// C154 — the speech-level search text mirrors the segments and is owned by
|
||||
// this bundle: `segments: undefined` (skip/ASR failure) preserves whatever
|
||||
// is stored, exactly like the segments themselves.
|
||||
const segmentSearchText =
|
||||
bundle.segments === undefined
|
||||
? undefined
|
||||
: normalizeForSearch(bundle.segments.map((segment) => segment.text).join(' '))
|
||||
|
||||
const metadata = metadataData(bundle)
|
||||
const facets: Partial<SpeechWriteData> = {}
|
||||
if (bundle.facets && !manualFacetsPreserved) {
|
||||
|
|
@ -195,7 +203,7 @@ export const upsertSpeechBundle = async (
|
|||
await payload.update({
|
||||
collection: 'speech',
|
||||
id: current.id,
|
||||
data,
|
||||
data: segmentSearchText === undefined ? data : { ...data, searchText: segmentSearchText },
|
||||
depth: 0,
|
||||
req,
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
|
|
@ -208,6 +216,7 @@ export const upsertSpeechBundle = async (
|
|||
data: {
|
||||
sourceKey: bundle.sourceKey,
|
||||
classifiedBy: bundle.facets?.classifiedBy ?? 'gazetteer',
|
||||
searchText: segmentSearchText ?? '',
|
||||
...data,
|
||||
},
|
||||
depth: 0,
|
||||
|
|
|
|||
58
src/utilities/speech/speechListFilters.ts
Normal file
58
src/utilities/speech/speechListFilters.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Speech acervo Payload `where` (C154). ONE place assembles the filters over
|
||||
* the speech row: the facet filters and the textual search, which matches the
|
||||
* normalized concatenation of the segments (all words, any order, accent-free)
|
||||
* OR an official keyword as an exact term.
|
||||
*/
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import { collapseListWhereOrBranches } from '@/utilities/campaignListUrl'
|
||||
import type { SpeechDurationBucket, SpeechListState } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
const DURATION_MEDIA_MIN_SECONDS = 120
|
||||
const DURATION_LONGA_MIN_SECONDS = 300
|
||||
|
||||
const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
|
||||
switch (bucket) {
|
||||
case 'curta':
|
||||
return { durationSeconds: { less_than: DURATION_MEDIA_MIN_SECONDS } }
|
||||
case 'media':
|
||||
return {
|
||||
durationSeconds: {
|
||||
greater_than_equal: DURATION_MEDIA_MIN_SECONDS,
|
||||
less_than: DURATION_LONGA_MIN_SECONDS,
|
||||
},
|
||||
}
|
||||
case 'longa':
|
||||
return { durationSeconds: { greater_than_equal: DURATION_LONGA_MIN_SECONDS } }
|
||||
case 'sem_duracao':
|
||||
return { durationSeconds: { exists: false } }
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSpeechListWhere = (state: SpeechListState): Where => {
|
||||
const filters: Where[] = []
|
||||
|
||||
if (state.years?.length) filters.push({ year: { in: state.years } })
|
||||
if (state.topics?.length) filters.push({ topics: { in: state.topics } })
|
||||
if (state.scopes?.length) filters.push({ scopes: { in: state.scopes } })
|
||||
if (state.phases?.length) filters.push({ phase: { in: state.phases } })
|
||||
if (state.municipalities?.length) {
|
||||
filters.push({ mentionedMunicipalities: { in: state.municipalities } })
|
||||
}
|
||||
if (state.durations?.length) {
|
||||
const branch = collapseListWhereOrBranches(state.durations.map(durationBucketWhere))
|
||||
if (branch) filters.push(branch)
|
||||
}
|
||||
if (state.q) {
|
||||
filters.push({
|
||||
or: [
|
||||
{ searchText: { like: normalizeForSearch(state.q) } },
|
||||
{ keywords: { contains: state.q } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
182
src/utilities/speech/speechListUrl.ts
Normal file
182
src/utilities/speech/speechListUrl.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Speech acervo URL contract (C154): state, param parse/canonicalize and
|
||||
* hrefs, same shape as `peopleListUrl`/`supporterUi` over the shared
|
||||
* `campaignListUrl` helpers. Facet enums are exhaustive (selecting every
|
||||
* member equals no filter); year/phase/municipality are data-driven and
|
||||
* validated structurally.
|
||||
*/
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import {
|
||||
SPEECH_SCOPES,
|
||||
SPEECH_TOPICS,
|
||||
type SpeechScope,
|
||||
type SpeechTopic,
|
||||
} from '@/lib/speechFacets'
|
||||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
firstValue,
|
||||
normalizedText,
|
||||
parseExhaustiveEnumParam,
|
||||
resolveListUrl,
|
||||
strictDecimalInteger,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
|
||||
export const speechPageSize = 25
|
||||
|
||||
/** Duration buckets are ranges over `durationSeconds`; `sem_duracao` is null. */
|
||||
export const SPEECH_DURATION_BUCKETS = [
|
||||
{ value: 'curta', label: 'Até 2 min' },
|
||||
{ value: 'media', label: '2 a 5 min' },
|
||||
{ value: 'longa', label: 'Mais de 5 min' },
|
||||
{ value: 'sem_duracao', label: 'Sem duração' },
|
||||
] as const
|
||||
|
||||
export type SpeechDurationBucket = (typeof SPEECH_DURATION_BUCKETS)[number]['value']
|
||||
|
||||
export const speechDurationLabels = Object.fromEntries(
|
||||
SPEECH_DURATION_BUCKETS.map(({ value, label }) => [value, label]),
|
||||
) as Record<SpeechDurationBucket, string>
|
||||
|
||||
export const speechTopicLabels = Object.fromEntries(
|
||||
SPEECH_TOPICS.map(({ value, label }) => [value, label]),
|
||||
) as Record<SpeechTopic, string>
|
||||
|
||||
export const speechScopeLabels = Object.fromEntries(
|
||||
SPEECH_SCOPES.map(({ value, label }) => [value, label]),
|
||||
) as Record<SpeechScope, string>
|
||||
|
||||
export type SpeechListState = {
|
||||
page: number
|
||||
q?: string
|
||||
years?: number[]
|
||||
topics?: SpeechTopic[]
|
||||
scopes?: SpeechScope[]
|
||||
phases?: string[]
|
||||
municipalities?: number[]
|
||||
durations?: SpeechDurationBucket[]
|
||||
}
|
||||
|
||||
/** Server-loaded filter options (years/phases present, cited municipalities). */
|
||||
export type SpeechFilterOptions = {
|
||||
years: number[]
|
||||
phases: string[]
|
||||
municipalities: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
type RawSpeechListParams = RawSearchParams
|
||||
|
||||
const speechListParamNames = [
|
||||
'q',
|
||||
'year',
|
||||
'topic',
|
||||
'scope',
|
||||
'phase',
|
||||
'municipality',
|
||||
'duration',
|
||||
'page',
|
||||
] as const
|
||||
|
||||
const speechListParamNameSet = new Set<string>(speechListParamNames)
|
||||
const speechTopicSet = new Set<string>(SPEECH_TOPICS.map(({ value }) => value))
|
||||
const speechScopeSet = new Set<string>(SPEECH_SCOPES.map(({ value }) => value))
|
||||
const speechDurationSet = new Set<string>(SPEECH_DURATION_BUCKETS.map(({ value }) => value))
|
||||
const MAX_PHASE_LENGTH = 80
|
||||
|
||||
const parseYearValues = (raw: string | string[] | undefined): number[] =>
|
||||
allParamValues(raw)
|
||||
.map((token) => strictDecimalInteger(token))
|
||||
.filter((year): year is number => year !== undefined && year >= 1900 && year <= 2100)
|
||||
|
||||
const parseMunicipalityValues = (raw: string | string[] | undefined): number[] =>
|
||||
allParamValues(raw)
|
||||
.map((token) => strictDecimalInteger(token))
|
||||
.filter((id): id is number => id !== undefined && id > 0)
|
||||
|
||||
export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListState => {
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const rawQ = normalizedText(firstValue(params.q))
|
||||
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
|
||||
const years = parseYearValues(params.year)
|
||||
const topics = parseExhaustiveEnumParam<SpeechTopic>(params.topic, speechTopicSet)
|
||||
const scopes = parseExhaustiveEnumParam<SpeechScope>(params.scope, speechScopeSet)
|
||||
const phases = allParamValues(params.phase).filter((token) => token.length <= MAX_PHASE_LENGTH)
|
||||
const municipalities = parseMunicipalityValues(params.municipality)
|
||||
const durations = parseExhaustiveEnumParam<SpeechDurationBucket>(
|
||||
params.duration,
|
||||
speechDurationSet,
|
||||
)
|
||||
|
||||
return {
|
||||
page: rawPage ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
...(years.length ? { years } : {}),
|
||||
...(topics.length ? { topics } : {}),
|
||||
...(scopes.length ? { scopes } : {}),
|
||||
...(phases.length ? { phases } : {}),
|
||||
...(municipalities.length ? { municipalities } : {}),
|
||||
...(durations.length ? { durations } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const speechListStateToRawParams = (
|
||||
state: SpeechListState,
|
||||
page = state.page,
|
||||
): RawSpeechListParams => ({
|
||||
page: String(page),
|
||||
q: state.q,
|
||||
year: state.years?.map(String),
|
||||
topic: state.topics,
|
||||
scope: state.scopes,
|
||||
phase: state.phases,
|
||||
municipality: state.municipalities?.map(String),
|
||||
duration: state.durations,
|
||||
})
|
||||
|
||||
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
|
||||
export const serializeCanonicalSpeechListSearchParams = (
|
||||
canonicalState: SpeechListState,
|
||||
): URLSearchParams => {
|
||||
const params = new URLSearchParams()
|
||||
if (canonicalState.q) params.set('q', canonicalState.q)
|
||||
for (const year of canonicalState.years ?? []) params.append('year', String(year))
|
||||
for (const topic of canonicalState.topics ?? []) params.append('topic', topic)
|
||||
for (const scope of canonicalState.scopes ?? []) params.append('scope', scope)
|
||||
for (const phase of canonicalState.phases ?? []) params.append('phase', phase)
|
||||
for (const municipality of canonicalState.municipalities ?? []) {
|
||||
params.append('municipality', String(municipality))
|
||||
}
|
||||
for (const duration of canonicalState.durations ?? []) params.append('duration', duration)
|
||||
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
|
||||
return params
|
||||
}
|
||||
|
||||
const buildSpeechListSearchParams = (state: SpeechListState, page = state.page): URLSearchParams =>
|
||||
serializeCanonicalSpeechListSearchParams(
|
||||
parseSpeechListParams(speechListStateToRawParams(state, page)),
|
||||
)
|
||||
|
||||
export const buildSpeechListHref = (state: SpeechListState, page: number): string =>
|
||||
buildListHref(state, buildSpeechListSearchParams, CAMPAIGN_COMMUNICATION_ACERVO, page)
|
||||
|
||||
export const buildSpeechFiltersKey = (state: SpeechListState): string =>
|
||||
buildSpeechListSearchParams(state).toString()
|
||||
|
||||
export const resolveSpeechListUrl = (
|
||||
params: RawSpeechListParams,
|
||||
totalPages?: number,
|
||||
): {
|
||||
state: SpeechListState
|
||||
href: string
|
||||
redirectHref?: string
|
||||
} =>
|
||||
resolveListUrl({
|
||||
params,
|
||||
paramNameSet: speechListParamNameSet,
|
||||
parse: parseSpeechListParams,
|
||||
buildSearchParams: buildSpeechListSearchParams,
|
||||
basePath: CAMPAIGN_COMMUNICATION_ACERVO,
|
||||
totalPages,
|
||||
})
|
||||
251
src/utilities/speech/speechOmnibox.ts
Normal file
251
src/utilities/speech/speechOmnibox.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Speech acervo omnibox adapter (C154). Pure / client-safe: builds the active
|
||||
* chips, the suggestion seeds (facet constants plus server-loaded years/phases/
|
||||
* municipalities) and the toggle/remove/clear actions over the URL state.
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import {
|
||||
SPEECH_SCOPES,
|
||||
SPEECH_TOPICS,
|
||||
type SpeechScope,
|
||||
type SpeechTopic,
|
||||
} from '@/lib/speechFacets'
|
||||
import {
|
||||
SPEECH_DURATION_BUCKETS,
|
||||
speechDurationLabels,
|
||||
speechScopeLabels,
|
||||
speechTopicLabels,
|
||||
type SpeechDurationBucket,
|
||||
type SpeechListState,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
|
||||
export type SpeechFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SpeechOmniboxAction =
|
||||
| { kind: 'url'; state: SpeechListState }
|
||||
| { kind: 'clear'; state: SpeechListState }
|
||||
|
||||
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
|
||||
|
||||
const withPageReset = (state: SpeechListState): SpeechListState => ({ ...state, page: 1 })
|
||||
|
||||
const toggleValue = <T>(values: T[] | undefined, value: T): T[] | undefined => {
|
||||
const current = values ?? []
|
||||
const next = current.includes(value)
|
||||
? current.filter((item) => item !== value)
|
||||
: [...current, value]
|
||||
return next.length ? next : undefined
|
||||
}
|
||||
|
||||
const removeValue = <T>(values: T[] | undefined, value: T): T[] | undefined => {
|
||||
const next = (values ?? []).filter((item) => item !== value)
|
||||
return next.length ? next : undefined
|
||||
}
|
||||
|
||||
const chipPrefix = (chipId: string): { key: string; value: string } => {
|
||||
const separator = chipId.indexOf(':')
|
||||
if (separator === -1) return { key: chipId, value: '' }
|
||||
return { key: chipId.slice(0, separator), value: chipId.slice(separator + 1) }
|
||||
}
|
||||
|
||||
type FacetMutation = 'toggle' | 'remove'
|
||||
|
||||
/**
|
||||
* ONE facet mutation for both omnibox actions: `toggle` (selecting a
|
||||
* suggestion) and `remove` (dropping a chip) differ only in the values
|
||||
* operation. Returns null when the id carries an invalid numeric value.
|
||||
*/
|
||||
const mutateFacet = (
|
||||
state: SpeechListState,
|
||||
key: string,
|
||||
value: string,
|
||||
mode: FacetMutation,
|
||||
): SpeechListState | null => {
|
||||
const mutate = mode === 'toggle' ? toggleValue : removeValue
|
||||
switch (key) {
|
||||
case 'year': {
|
||||
const year = Number(value)
|
||||
if (!Number.isSafeInteger(year)) return null
|
||||
return { ...state, years: mutate(state.years, year) }
|
||||
}
|
||||
case 'topic':
|
||||
return { ...state, topics: mutate(state.topics, value as SpeechTopic) }
|
||||
case 'scope':
|
||||
return { ...state, scopes: mutate(state.scopes, value as SpeechScope) }
|
||||
case 'phase':
|
||||
return { ...state, phases: mutate(state.phases, value) }
|
||||
case 'municipality': {
|
||||
const municipality = Number(value)
|
||||
if (!Number.isSafeInteger(municipality)) return null
|
||||
return { ...state, municipalities: mutate(state.municipalities, municipality) }
|
||||
}
|
||||
case 'duration':
|
||||
return { ...state, durations: mutate(state.durations, value as SpeechDurationBucket) }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSpeechOmniboxChips = ({
|
||||
state,
|
||||
municipalityLabelsById,
|
||||
}: {
|
||||
state: SpeechListState
|
||||
municipalityLabelsById: ReadonlyMap<number, string>
|
||||
}): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
|
||||
for (const year of state.years ?? []) {
|
||||
chips.push({ id: `year:${year}`, label: chipLabel('Ano', String(year)) })
|
||||
}
|
||||
for (const topic of state.topics ?? []) {
|
||||
chips.push({ id: `topic:${topic}`, label: chipLabel('Tema', speechTopicLabels[topic]) })
|
||||
}
|
||||
for (const scope of state.scopes ?? []) {
|
||||
chips.push({ id: `scope:${scope}`, label: chipLabel('Alcance', speechScopeLabels[scope]) })
|
||||
}
|
||||
for (const phase of state.phases ?? []) {
|
||||
chips.push({ id: `phase:${phase}`, label: chipLabel('Fase', phase) })
|
||||
}
|
||||
for (const municipality of state.municipalities ?? []) {
|
||||
chips.push({
|
||||
id: `municipality:${municipality}`,
|
||||
label: chipLabel(
|
||||
'Município',
|
||||
municipalityLabelsById.get(municipality) ?? `Município #${municipality}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
for (const duration of state.durations ?? []) {
|
||||
chips.push({
|
||||
id: `duration:${duration}`,
|
||||
label: chipLabel('Duração', speechDurationLabels[duration]),
|
||||
})
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
export const buildSpeechOmniboxSuggestionSeeds = ({
|
||||
years,
|
||||
phases,
|
||||
municipalityOptions,
|
||||
}: {
|
||||
years: readonly number[]
|
||||
phases: readonly string[]
|
||||
municipalityOptions: readonly SpeechFilterOption[]
|
||||
}) => {
|
||||
const seeds = []
|
||||
|
||||
for (const { value, label } of SPEECH_TOPICS) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{ id: `topic:${value}`, group: 'Tema', label, keywords: ['tema'] },
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const { value, label } of SPEECH_SCOPES) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{ id: `scope:${value}`, group: 'Alcance', label, keywords: ['alcance'] },
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const { value, label } of SPEECH_DURATION_BUCKETS) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{ id: `duration:${value}`, group: 'Duração', label, keywords: ['duracao', 'tempo'] },
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const year of years) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `year:${year}`,
|
||||
group: 'Ano',
|
||||
label: String(year),
|
||||
keywords: ['ano'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const phase of phases) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `phase:${phase}`,
|
||||
group: 'Fase',
|
||||
label: phase,
|
||||
keywords: ['fase', 'sessao'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of municipalityOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `municipality:${option.value}`,
|
||||
group: 'Município',
|
||||
label: option.label,
|
||||
keywords: ['municipio', 'cidade'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
export const filterSpeechOmniboxSuggestions = (
|
||||
seeds: ReturnType<typeof buildSpeechOmniboxSuggestionSeeds>,
|
||||
query: string,
|
||||
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
|
||||
|
||||
export const applySpeechOmniboxSuggestion = ({
|
||||
state,
|
||||
suggestionId,
|
||||
}: {
|
||||
state: SpeechListState
|
||||
suggestionId: string
|
||||
}): SpeechOmniboxAction => {
|
||||
if (suggestionId.startsWith('q:')) {
|
||||
const q = suggestionId.slice(2).trim()
|
||||
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
|
||||
}
|
||||
|
||||
const { key, value } = chipPrefix(suggestionId)
|
||||
const next = mutateFacet(withPageReset(state), key, value, 'toggle')
|
||||
return { kind: 'url', state: next ?? state }
|
||||
}
|
||||
|
||||
export const removeSpeechOmniboxChip = ({
|
||||
state,
|
||||
chipId,
|
||||
}: {
|
||||
state: SpeechListState
|
||||
chipId: string
|
||||
}): SpeechOmniboxAction => {
|
||||
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
|
||||
|
||||
const { key, value } = chipPrefix(chipId)
|
||||
const next = mutateFacet(withPageReset(state), key, value, 'remove')
|
||||
return { kind: 'url', state: next ?? state }
|
||||
}
|
||||
|
||||
export const clearSpeechOmnibox = (_state: SpeechListState): SpeechOmniboxAction => ({
|
||||
kind: 'clear',
|
||||
state: { page: 1 },
|
||||
})
|
||||
249
src/utilities/speech/speechPageData.ts
Normal file
249
src/utilities/speech/speechPageData.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser, Speech } from '@/payload-types'
|
||||
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
|
||||
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
|
||||
import { buildSpeechListWhere } from '@/utilities/speech/speechListFilters'
|
||||
import {
|
||||
resolveSpeechListUrl,
|
||||
speechPageSize,
|
||||
type SpeechFilterOptions,
|
||||
type SpeechListState,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
import {
|
||||
municipalityIdsOfSpeech,
|
||||
toSpeechDetailViewModel,
|
||||
toSpeechListItemViewModel,
|
||||
type SpeechDetailRecord,
|
||||
type SpeechDetailViewModel,
|
||||
type SpeechListItemViewModel,
|
||||
type SpeechSegmentRecord,
|
||||
} from '@/utilities/speech/speechViewModels'
|
||||
|
||||
type SpeechListSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export const SpeechNotFoundError = createEntityNotFoundError('Speech', 'Fala não encontrada.')
|
||||
|
||||
const speechListSelect = {
|
||||
speechAt: true,
|
||||
type: true,
|
||||
durationSeconds: true,
|
||||
summary: true,
|
||||
officialTranscript: true,
|
||||
keywords: true,
|
||||
topics: true,
|
||||
scopes: true,
|
||||
mentionedMunicipalities: true,
|
||||
presidingOfficer: true,
|
||||
vodDownloadUrl: true,
|
||||
officialTextUrl: true,
|
||||
youtubeUrl: true,
|
||||
} as const
|
||||
|
||||
const speechDetailSelect = {
|
||||
...speechListSelect,
|
||||
phase: true,
|
||||
vodPlaybackUrl: true,
|
||||
} as const
|
||||
|
||||
const segmentSelect = {
|
||||
speech: true,
|
||||
startSeconds: true,
|
||||
endSeconds: true,
|
||||
text: true,
|
||||
} as const
|
||||
|
||||
const municipalityNameMap = (
|
||||
labels: Awaited<ReturnType<typeof loadMunicipalityLabelsByIds>>,
|
||||
): Map<number, string> => new Map([...labels.entries()].map(([id, entry]) => [id, entry.name]))
|
||||
|
||||
/**
|
||||
* Options the URL filters offer: the years and phases actually present in the
|
||||
* catalog and the municipalities cited by at least one speech (labels resolved
|
||||
* through the justified `loadMunicipalityLabelsByIds` bypass — the ids come
|
||||
* from speeches the actor was already authorized to read).
|
||||
*/
|
||||
const loadSpeechFilterOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<SpeechFilterOptions> => {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { year: true, phase: true, mentionedMunicipalities: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const years = new Set<number>()
|
||||
const phases = new Set<string>()
|
||||
const municipalityIds = new Set<number>()
|
||||
for (const speech of result.docs) {
|
||||
if (typeof speech.year === 'number') years.add(speech.year)
|
||||
if (speech.phase) phases.add(speech.phase)
|
||||
for (const id of municipalityIdsOfSpeech(speech)) municipalityIds.add(id)
|
||||
}
|
||||
|
||||
const labels = await loadMunicipalityLabelsByIds(payload, [...municipalityIds])
|
||||
const municipalities = [...municipalityIds]
|
||||
.flatMap((id) => {
|
||||
const entry = labels.get(id)
|
||||
return entry ? [{ value: String(id), label: entry.name }] : []
|
||||
})
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
||||
|
||||
return {
|
||||
years: [...years].sort((left, right) => right - left),
|
||||
phases: [...phases].sort((left, right) => left.localeCompare(right, 'pt-BR')),
|
||||
municipalities,
|
||||
}
|
||||
}
|
||||
|
||||
const loadSegmentsForSpeeches = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
speechIds: readonly number[],
|
||||
): Promise<Map<number, SpeechSegmentRecord[]>> => {
|
||||
const bySpeech = new Map<number, SpeechSegmentRecord[]>()
|
||||
if (speechIds.length === 0) return bySpeech
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { in: [...speechIds] } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'order',
|
||||
select: segmentSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
for (const segment of result.docs) {
|
||||
const speechId = typeof segment.speech === 'number' ? segment.speech : segment.speech.id
|
||||
const list = bySpeech.get(speechId) ?? []
|
||||
list.push({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
})
|
||||
bySpeech.set(speechId, list)
|
||||
}
|
||||
return bySpeech
|
||||
}
|
||||
|
||||
export type SpeechAcervoPageData = {
|
||||
rows: SpeechListItemViewModel[]
|
||||
state: SpeechListState
|
||||
redirectHref?: string
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
filterOptions: SpeechFilterOptions
|
||||
}
|
||||
|
||||
export const loadSpeechAcervoPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
|
||||
): Promise<SpeechAcervoPageData> => {
|
||||
const rawSearchParams = await searchParams
|
||||
const canonicalUrl = resolveSpeechListUrl(rawSearchParams)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
depth: 0,
|
||||
limit: speechPageSize,
|
||||
page: canonicalUrl.state.page,
|
||||
sort: '-speechAt',
|
||||
where: buildSpeechListWhere(canonicalUrl.state),
|
||||
select: speechListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const resolvedUrl = resolveSpeechListUrl(rawSearchParams, result.totalPages)
|
||||
const speeches = result.docs as Speech[]
|
||||
|
||||
const [segmentsBySpeech, filterOptions] = await Promise.all([
|
||||
loadSegmentsForSpeeches(
|
||||
payload,
|
||||
user,
|
||||
speeches.map((speech) => speech.id),
|
||||
),
|
||||
loadSpeechFilterOptions(payload, user),
|
||||
])
|
||||
|
||||
const municipalityIds = new Set<number>()
|
||||
for (const speech of speeches) {
|
||||
for (const id of municipalityIdsOfSpeech(speech)) municipalityIds.add(id)
|
||||
}
|
||||
const municipalityLabels = municipalityNameMap(
|
||||
await loadMunicipalityLabelsByIds(payload, [...municipalityIds]),
|
||||
)
|
||||
|
||||
return {
|
||||
rows: speeches.map((speech) =>
|
||||
toSpeechListItemViewModel({
|
||||
speech,
|
||||
segments: segmentsBySpeech.get(speech.id) ?? [],
|
||||
query: canonicalUrl.state.q,
|
||||
municipalityLabels,
|
||||
}),
|
||||
),
|
||||
state: resolvedUrl.state,
|
||||
redirectHref: resolvedUrl.redirectHref ?? canonicalUrl.redirectHref,
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
filterOptions,
|
||||
}
|
||||
}
|
||||
|
||||
export const loadSpeechDetailPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
speechId: number,
|
||||
query?: string,
|
||||
): Promise<SpeechDetailViewModel> => {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { id: { equals: speechId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: speechDetailSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const speech = result.docs[0]
|
||||
if (!speech) throw new SpeechNotFoundError()
|
||||
|
||||
const [segments, municipalityLabels] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { equals: speechId } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'order',
|
||||
select: segmentSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
loadMunicipalityLabelsByIds(payload, municipalityIdsOfSpeech(speech)).then(municipalityNameMap),
|
||||
])
|
||||
|
||||
return toSpeechDetailViewModel({
|
||||
speech: speech as SpeechDetailRecord,
|
||||
segments: segments.docs.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
})),
|
||||
query,
|
||||
municipalityLabels,
|
||||
})
|
||||
}
|
||||
263
src/utilities/speech/speechViewModels.ts
Normal file
263
src/utilities/speech/speechViewModels.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* Speech acervo view models (C154). Pure: the loader hands raw Payload rows
|
||||
* and the view model decides labels, the matching excerpt and the links the
|
||||
* list/detail render.
|
||||
*/
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
|
||||
import {
|
||||
buildHighlightedExcerpt,
|
||||
matchesSearchTerms,
|
||||
speechSearchTerms,
|
||||
splitHighlightedParts,
|
||||
type SpeechHighlightedExcerpt,
|
||||
type SpeechHighlightPart,
|
||||
} from '@/lib/speechHighlight'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
export type SpeechSegmentRecord = {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SpeechListRecord = {
|
||||
id: number
|
||||
speechAt: string
|
||||
type?: string | null
|
||||
durationSeconds?: number | null
|
||||
summary?: string | null
|
||||
officialTranscript?: string | null
|
||||
keywords?: string[] | null
|
||||
topics?: SpeechTopic[] | null
|
||||
scopes?: SpeechScope[] | null
|
||||
mentionedMunicipalities?: (number | Municipality)[] | null
|
||||
presidingOfficer?: string | null
|
||||
vodDownloadUrl?: string | null
|
||||
officialTextUrl?: string | null
|
||||
youtubeUrl?: string | null
|
||||
}
|
||||
|
||||
type SpeechListMatchKind = 'segment' | 'keyword' | 'fallback'
|
||||
|
||||
export type SpeechListItemViewModel = {
|
||||
id: number
|
||||
speechAtLabel: string
|
||||
type: string | null
|
||||
durationLabel: string | null
|
||||
presidingOfficer: string | null
|
||||
excerpt: SpeechHighlightedExcerpt
|
||||
matchKind: SpeechListMatchKind
|
||||
topics: { value: SpeechTopic; label: string }[]
|
||||
scopes: { value: SpeechScope; label: string }[]
|
||||
keywords: string[]
|
||||
municipalities: { id: number; name: string }[]
|
||||
watchHref: string
|
||||
downloadUrl: string | null
|
||||
sourceUrl: string | null
|
||||
}
|
||||
|
||||
export type SpeechDetailSegmentViewModel = {
|
||||
startSeconds: number
|
||||
endSeconds: number
|
||||
startLabel: string
|
||||
parts: SpeechHighlightPart[]
|
||||
}
|
||||
|
||||
export type SpeechDetailViewModel = {
|
||||
id: number
|
||||
speechAtLabel: string
|
||||
type: string | null
|
||||
phase: string | null
|
||||
durationLabel: string | null
|
||||
presidingOfficer: string | null
|
||||
summary: string | null
|
||||
officialTranscript: string | null
|
||||
officialTextUrl: string | null
|
||||
youtubeUrl: string | null
|
||||
keywords: string[]
|
||||
topics: { value: SpeechTopic; label: string }[]
|
||||
scopes: { value: SpeechScope; label: string }[]
|
||||
municipalities: { id: number; name: string }[]
|
||||
segments: SpeechDetailSegmentViewModel[]
|
||||
vodPlaybackUrl: string | null
|
||||
vodDownloadUrl: string | null
|
||||
}
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
/**
|
||||
* `speechAt` is the Câmara wall-clock string ("2026-08-11T18:48", no timezone);
|
||||
* slicing it keeps the local reading and avoids a `Date` shifting it.
|
||||
*/
|
||||
const formatSpeechAt = (speechAt: string): string => {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/.exec(speechAt)
|
||||
if (!match) return speechAt
|
||||
const [, year, month, day, hour, minute] = match
|
||||
return `${day}/${month}/${year} · ${hour}:${minute}`
|
||||
}
|
||||
|
||||
const formatSpeechDuration = (seconds: number | null | undefined): string | null => {
|
||||
if (seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) {
|
||||
return null
|
||||
}
|
||||
const total = Math.round(seconds)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
if (hours > 0) return `${hours}h${pad(minutes)}min`
|
||||
if (minutes > 0) return `${minutes}min${pad(secs)}s`
|
||||
return `${secs}s`
|
||||
}
|
||||
|
||||
/** Transcript timestamp ("01:12" / "1:02:03"). */
|
||||
const formatSpeechClock = (seconds: number): string => {
|
||||
const total = Math.max(0, Math.round(seconds))
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const secs = total % 60
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${pad(minutes)}:${pad(secs)}`
|
||||
}
|
||||
|
||||
type MunicipalityMentions = {
|
||||
mentionedMunicipalities?: (number | Municipality)[] | null
|
||||
}
|
||||
|
||||
/** Cited municipality ids from a speech row (depth 0 keeps them as numbers). */
|
||||
export const municipalityIdsOfSpeech = (speech: MunicipalityMentions): number[] =>
|
||||
(speech.mentionedMunicipalities ?? []).flatMap((value) =>
|
||||
typeof value === 'number' ? [value] : [value.id],
|
||||
)
|
||||
|
||||
const municipalityViewModels = (
|
||||
speech: SpeechListRecord,
|
||||
labels: ReadonlyMap<number, string>,
|
||||
): { id: number; name: string }[] =>
|
||||
(speech.mentionedMunicipalities ?? []).flatMap((value) => {
|
||||
if (typeof value !== 'number') return [{ id: value.id, name: value.name }]
|
||||
const name = labels.get(value)
|
||||
return name ? [{ id: value, name }] : []
|
||||
})
|
||||
|
||||
const topicViewModels = (speech: SpeechListRecord) =>
|
||||
(speech.topics ?? []).map((value) => ({ value, label: speechTopicLabels[value] }))
|
||||
|
||||
const scopeViewModels = (speech: SpeechListRecord) =>
|
||||
(speech.scopes ?? []).map((value) => ({ value, label: speechScopeLabels[value] }))
|
||||
|
||||
const pickMatchingSegment = (
|
||||
segments: readonly SpeechSegmentRecord[],
|
||||
query: string | undefined,
|
||||
): SpeechSegmentRecord | undefined => {
|
||||
const q = query?.trim()
|
||||
if (!q || segments.length === 0) return undefined
|
||||
const allTerms = segments.find((segment) => matchesSearchTerms(segment.text, q))
|
||||
if (allTerms) return allTerms
|
||||
const terms = speechSearchTerms(q)
|
||||
return segments.find((segment) => {
|
||||
const normalized = normalizeForSearch(segment.text)
|
||||
return terms.some((term) => normalized.includes(term))
|
||||
})
|
||||
}
|
||||
|
||||
const buildWatchHref = (
|
||||
speechId: number,
|
||||
segment: SpeechSegmentRecord | undefined,
|
||||
query: string | undefined,
|
||||
): string => {
|
||||
const params = new URLSearchParams()
|
||||
// ASR timestamps are fractional seconds; the URL carries whole seconds.
|
||||
if (segment) params.set('t', String(Math.max(0, Math.floor(segment.startSeconds))))
|
||||
if (query) params.set('q', query)
|
||||
const queryString = params.toString()
|
||||
return `${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}${queryString ? `?${queryString}` : ''}`
|
||||
}
|
||||
|
||||
/** Seek offset from the detail URL (`?t=`), non-negative seconds or null. */
|
||||
export const parseSpeechSeekSeconds = (raw: string | undefined): number | null => {
|
||||
if (raw === undefined) return null
|
||||
const seconds = Number(raw)
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null
|
||||
}
|
||||
|
||||
export const toSpeechListItemViewModel = ({
|
||||
speech,
|
||||
segments,
|
||||
query,
|
||||
municipalityLabels,
|
||||
}: {
|
||||
speech: SpeechListRecord
|
||||
segments: readonly SpeechSegmentRecord[]
|
||||
query?: string
|
||||
municipalityLabels: ReadonlyMap<number, string>
|
||||
}): SpeechListItemViewModel => {
|
||||
const matchedSegment = pickMatchingSegment(segments, query)
|
||||
const q = query?.trim()
|
||||
const normalizedQuery = q ? normalizeForSearch(q) : ''
|
||||
const keywordMatch =
|
||||
!matchedSegment &&
|
||||
Boolean(normalizedQuery) &&
|
||||
(speech.keywords ?? []).some((keyword) => normalizeForSearch(keyword).includes(normalizedQuery))
|
||||
|
||||
const excerptSource =
|
||||
matchedSegment?.text ?? segments[0]?.text ?? speech.summary ?? speech.officialTranscript ?? ''
|
||||
|
||||
return {
|
||||
id: speech.id,
|
||||
speechAtLabel: formatSpeechAt(speech.speechAt),
|
||||
type: speech.type ?? null,
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
presidingOfficer: speech.presidingOfficer ?? null,
|
||||
excerpt: buildHighlightedExcerpt(excerptSource, q ?? ''),
|
||||
matchKind: matchedSegment ? 'segment' : keywordMatch ? 'keyword' : 'fallback',
|
||||
topics: topicViewModels(speech),
|
||||
scopes: scopeViewModels(speech),
|
||||
keywords: speech.keywords ?? [],
|
||||
municipalities: municipalityViewModels(speech, municipalityLabels),
|
||||
watchHref: buildWatchHref(speech.id, matchedSegment, q),
|
||||
downloadUrl: speech.vodDownloadUrl ?? null,
|
||||
sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export type SpeechDetailRecord = SpeechListRecord & {
|
||||
phase?: string | null
|
||||
vodPlaybackUrl?: string | null
|
||||
}
|
||||
|
||||
export const toSpeechDetailViewModel = ({
|
||||
speech,
|
||||
segments,
|
||||
query,
|
||||
municipalityLabels,
|
||||
}: {
|
||||
speech: SpeechDetailRecord
|
||||
segments: readonly SpeechSegmentRecord[]
|
||||
query?: string
|
||||
municipalityLabels: ReadonlyMap<number, string>
|
||||
}): SpeechDetailViewModel => ({
|
||||
id: speech.id,
|
||||
speechAtLabel: formatSpeechAt(speech.speechAt),
|
||||
type: speech.type ?? null,
|
||||
phase: speech.phase ?? null,
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
presidingOfficer: speech.presidingOfficer ?? null,
|
||||
summary: speech.summary ?? null,
|
||||
officialTranscript: speech.officialTranscript ?? null,
|
||||
officialTextUrl: speech.officialTextUrl ?? null,
|
||||
youtubeUrl: speech.youtubeUrl ?? null,
|
||||
keywords: speech.keywords ?? [],
|
||||
topics: topicViewModels(speech),
|
||||
scopes: scopeViewModels(speech),
|
||||
municipalities: municipalityViewModels(speech, municipalityLabels),
|
||||
segments: segments.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
startLabel: formatSpeechClock(segment.startSeconds),
|
||||
parts: splitHighlightedParts(segment.text, query ?? ''),
|
||||
})),
|
||||
vodPlaybackUrl: speech.vodPlaybackUrl ?? null,
|
||||
vodDownloadUrl: speech.vodDownloadUrl ?? null,
|
||||
})
|
||||
199
tests/e2e/campaignSpeechAcervo.e2e.spec.ts
Normal file
199
tests/e2e/campaignSpeechAcervo.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
|
||||
|
||||
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
|
||||
import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
|
||||
|
||||
/**
|
||||
* C154 — the communication vertical over real HTTP (browserless, OPS35/OPS87):
|
||||
* role gates, the acervo search rendering (highlight + excerpt), the filter
|
||||
* URL contract and the detail's player/transcript/actions. Playback itself is
|
||||
* browser territory and is not asserted here — the HTML carries the contract
|
||||
* (`<video src>`, `data-start-seconds`).
|
||||
*/
|
||||
|
||||
const createSpeech = async (
|
||||
campaign: { fixtures: CampaignE2EOwnership },
|
||||
input: {
|
||||
marker: string
|
||||
speechAt?: string
|
||||
topics?: SpeechTopic[]
|
||||
scopes?: SpeechScope[]
|
||||
keywords?: string[]
|
||||
durationSeconds?: number
|
||||
withVideo?: boolean
|
||||
},
|
||||
) => {
|
||||
const marker = input.marker
|
||||
const speech = await campaign.fixtures.payload.create({
|
||||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: campaign.fixtures.value('speech'),
|
||||
speechAt: input.speechAt ?? '2026-03-10T18:00',
|
||||
type: 'BREVES COMUNICAÇÕES',
|
||||
phase: 'Breves Comunicações',
|
||||
durationSeconds: input.durationSeconds ?? 252,
|
||||
summary: `Sumário oficial com o marcador ${marker}.`,
|
||||
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
|
||||
keywords: input.keywords ?? ['Farmácia Popular'],
|
||||
topics: input.topics ?? ['saude'],
|
||||
scopes: input.scopes ?? ['bahia'],
|
||||
classifiedBy: 'gazetteer',
|
||||
// Mirrors what `upsertSpeechBundle` derives from the segments (C154).
|
||||
searchText: `a retomada da saude ${marker} na bahia`,
|
||||
presidingOfficer: 'Pompeo de Mattos',
|
||||
officialTextUrl: 'https://camara.leg.br/discurso',
|
||||
...(input.withVideo === false
|
||||
? {}
|
||||
: {
|
||||
vodPlaybackUrl: 'https://vod.camara.leg.br/excerpt.mp4',
|
||||
vodDownloadUrl: 'https://vod.camara.leg.br/excerpt.mp4',
|
||||
}),
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
await campaign.fixtures.payload.create({
|
||||
collection: 'speechSegment',
|
||||
data: {
|
||||
speech: speech.id,
|
||||
order: 1,
|
||||
startSeconds: 0,
|
||||
endSeconds: 4,
|
||||
text: `A retomada da saúde ${marker} na Bahia`,
|
||||
searchText: `a retomada da saude ${marker} na bahia`,
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
return speech
|
||||
}
|
||||
|
||||
test.describe('communication vertical (C154)', () => {
|
||||
test('communicator lands on the acervo and sees only the vertical nav', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
await assertCampaignRedirect(request, '/campanha', '/campanha/comunicacao')
|
||||
await assertCampaignRedirect(request, '/campanha/comunicacao', '/campanha/comunicacao/acervo')
|
||||
|
||||
const response = await request.get('/campanha/comunicacao/acervo')
|
||||
expect(response.status()).toBe(200)
|
||||
const html = rendered(await response.text())
|
||||
expect(html).toContain('Acervo de falas')
|
||||
expect(html).toContain('href="/campanha/comunicacao"')
|
||||
expect(html).not.toContain('href="/campanha/apoiadores"')
|
||||
expect(html).not.toContain('href="/campanha/municipios"')
|
||||
// C154 — no Sollinha for the communication assessor (C153 debt resolved).
|
||||
expect(html).not.toContain('campaign-ai-shell')
|
||||
expect(html).not.toContain('Sollinha')
|
||||
})
|
||||
|
||||
test('coordinator sees the vertical from the staff nav; advisor does not', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const coordinator = await campaign.fixtures.createCampaignUser('coordinator')
|
||||
const coordinatorRequest = await campaignRequest(coordinator, coordinator.password)
|
||||
const coordinatorHome = await coordinatorRequest.get('/campanha')
|
||||
expect(coordinatorHome.status()).toBe(200)
|
||||
expect(rendered(await coordinatorHome.text())).toContain('href="/campanha/comunicacao"')
|
||||
|
||||
const advisor = await campaign.fixtures.createCampaignUser('advisor')
|
||||
const advisorRequest = await campaignRequest(advisor, advisor.password)
|
||||
const advisorHome = await advisorRequest.get('/campanha')
|
||||
expect(advisorHome.status()).toBe(200)
|
||||
expect(rendered(await advisorHome.text())).not.toContain('href="/campanha/comunicacao"')
|
||||
})
|
||||
|
||||
test('search renders the highlighted excerpt and the detail carries the player', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('falateste')
|
||||
const speech = await createSpeech(campaign, { marker })
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const results = await request.get(`/campanha/comunicacao/acervo?q=${marker}`)
|
||||
expect(results.status()).toBe(200)
|
||||
const resultsHtml = rendered(await results.text())
|
||||
expect(resultsHtml).toContain(marker)
|
||||
expect(resultsHtml).toContain('<mark')
|
||||
expect(resultsHtml).toContain('Assistir no trecho')
|
||||
expect(resultsHtml).toContain('1 fala encontrada')
|
||||
|
||||
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}?t=0&q=${marker}`)
|
||||
expect(detail.status()).toBe(200)
|
||||
const detailHtml = rendered(await detail.text())
|
||||
expect(detailHtml).toContain('data-slot="speech-player"')
|
||||
expect(detailHtml).toContain('<video')
|
||||
expect(detailHtml).toContain('data-start-seconds="0"')
|
||||
expect(detailHtml).toContain('Baixar vídeo (MP4)')
|
||||
expect(detailHtml).toContain('Abrir fonte')
|
||||
expect(detailHtml).toContain('Fonte: Câmara dos Deputados')
|
||||
})
|
||||
|
||||
test('facet filters narrow the results and the empty state suggests clearing', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('filtroteste')
|
||||
await createSpeech(campaign, {
|
||||
marker,
|
||||
speechAt: '2019-05-10T15:00',
|
||||
topics: ['igualdade-racial'],
|
||||
scopes: ['brasil'],
|
||||
durationSeconds: 90,
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const matching = await request.get(
|
||||
`/campanha/comunicacao/acervo?q=${marker}&topic=igualdade-racial&year=2019&duration=curta`,
|
||||
)
|
||||
expect(rendered(await matching.text())).toContain(marker)
|
||||
|
||||
const filteredOut = await request.get(`/campanha/comunicacao/acervo?q=${marker}&topic=saude`)
|
||||
const filteredHtml = rendered(await filteredOut.text())
|
||||
// The term still rides the search chip; what must be gone is the result.
|
||||
expect(filteredHtml).not.toContain('Assistir no trecho')
|
||||
expect(filteredHtml).toContain('Nenhuma fala encontrada')
|
||||
expect(filteredHtml).toContain('Limpar busca e filtros')
|
||||
})
|
||||
|
||||
test('advisor and leader are redirected away from the acervo', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const advisor = await campaign.fixtures.createCampaignUser('advisor')
|
||||
const advisorRequest = await campaignRequest(advisor, advisor.password)
|
||||
await assertCampaignRedirect(advisorRequest, '/campanha/comunicacao/acervo', '/campanha')
|
||||
|
||||
const leader = await campaign.fixtures.createCampaignUser('leader')
|
||||
const leaderRequest = await campaignRequest(leader, leader.password)
|
||||
await assertCampaignRedirect(
|
||||
leaderRequest,
|
||||
'/campanha/comunicacao/acervo',
|
||||
'/campanha/meus-contatos',
|
||||
)
|
||||
})
|
||||
|
||||
test('a speech without VOD renders the unavailable block instead of a player', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('semvod')
|
||||
const speech = await createSpeech(campaign, { marker, withVideo: false })
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
|
||||
const html = rendered(await detail.text())
|
||||
expect(html).toContain('VOD indisponível')
|
||||
expect(html).not.toContain('Baixar vídeo (MP4)')
|
||||
})
|
||||
})
|
||||
|
|
@ -42,6 +42,10 @@ type OwnedCollection =
|
|||
| 'supporter'
|
||||
| 'calendarFeed'
|
||||
| 'googleCalendarSync'
|
||||
// C154 — the speech acervo fixtures; segments go before the speech so the
|
||||
// bulk cleanup never depends on the collection's cascade hook.
|
||||
| 'speech'
|
||||
| 'speechSegment'
|
||||
|
||||
const deletionOrder: OwnedCollection[] = [
|
||||
'campaignInvite',
|
||||
|
|
@ -52,6 +56,8 @@ const deletionOrder: OwnedCollection[] = [
|
|||
'activity',
|
||||
'calendarFeed',
|
||||
'googleCalendarSync',
|
||||
'speechSegment',
|
||||
'speech',
|
||||
'leadership',
|
||||
'supporter',
|
||||
'organization',
|
||||
|
|
@ -420,6 +426,24 @@ export class CampaignE2EOwnership {
|
|||
})
|
||||
for (const feed of feeds.docs) this.own('calendarFeed', feed.id)
|
||||
}
|
||||
// C154 — speeches carry the run marker in `sourceKey`; their segments are
|
||||
// discovered through the owned speeches (crashed-run safety net).
|
||||
const speeches = await this.rootPayload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { contains: this.runID } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
})
|
||||
for (const speech of speeches.docs) this.own('speech', speech.id)
|
||||
if (speeches.docs.length) {
|
||||
const segments = await this.rootPayload.find({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { in: speeches.docs.map((speech) => speech.id) } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
})
|
||||
for (const segment of segments.docs) this.own('speechSegment', segment.id)
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -121,21 +121,28 @@ export { expect }
|
|||
export const rendered = (html: string) => html.replaceAll('<!-- -->', '')
|
||||
|
||||
/**
|
||||
* The route-gate redirect contract (OPS35 → OPS87, 4 call sites): `noLeader`/
|
||||
* `staff` gates throw `redirect()` deep in the page, after the layout has
|
||||
* started streaming — so the document usually answers 200 carrying Next's
|
||||
* route-redirect meta tag instead of a 3xx; when the redirect beats the
|
||||
* stream it is a real 3xx (dev 307 / prod 308). Both are the server contract
|
||||
* the browser follows — assert the redirect TARGET, never pin the transport.
|
||||
* The route-gate redirect contract (OPS35 → OPS87, 4 call sites; C154
|
||||
* generalized the target): `noLeader`/`staff`/`speechCatalog` gates throw
|
||||
* `redirect()` deep in the page, after the layout has started streaming — so
|
||||
* the document usually answers 200 carrying Next's route-redirect meta tag
|
||||
* instead of a 3xx; when the redirect beats the stream it is a real 3xx (dev
|
||||
* 307 / prod 308). Both are the server contract the browser follows — assert
|
||||
* the redirect TARGET, never pin the transport.
|
||||
*/
|
||||
export const assertLeaderRedirect = async (request: APIRequestContext, path: string) => {
|
||||
export const assertCampaignRedirect = async (
|
||||
request: APIRequestContext,
|
||||
path: string,
|
||||
target: string,
|
||||
) => {
|
||||
const direct = await request.get(path, { maxRedirects: 0 })
|
||||
expect([200, 307, 308]).toContain(direct.status())
|
||||
if (direct.status() === 200) {
|
||||
expect(await direct.text()).toContain(
|
||||
'http-equiv="refresh" content="1;url=/campanha/meus-contatos"',
|
||||
)
|
||||
expect(await direct.text()).toContain(`http-equiv="refresh" content="1;url=${target}"`)
|
||||
} else {
|
||||
expect(direct.headers()['location']).toMatch(/\/campanha\/meus-contatos$/)
|
||||
const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
expect(direct.headers()['location']).toMatch(new RegExp(`${escaped}$`))
|
||||
}
|
||||
}
|
||||
|
||||
export const assertLeaderRedirect = (request: APIRequestContext, path: string) =>
|
||||
assertCampaignRedirect(request, path, '/campanha/meus-contatos')
|
||||
|
|
|
|||
46
tests/helpers/speechBundleFixture.ts
Normal file
46
tests/helpers/speechBundleFixture.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
|
||||
import type { SpeechImportBundle } from '@/utilities/speech/speechImport'
|
||||
|
||||
/**
|
||||
* Shared speech import bundle fixture (C153/C154 int specs): one Câmara-shaped
|
||||
* bundle with deterministic metadata and a searchable segment. Overrides
|
||||
* replace any field; the caller owns the `sourceKey` cleanup bookkeeping.
|
||||
*/
|
||||
export const speechBundleFixture = (
|
||||
overrides: Partial<SpeechImportBundle> = {},
|
||||
): SpeechImportBundle => ({
|
||||
sourceKey: `test-import-${randomUUID()}`,
|
||||
speechAt: '2023-02-07T17:28',
|
||||
year: 2023,
|
||||
legislature: '57',
|
||||
type: 'BREVES COMUNICAÇÕES',
|
||||
phase: 'Breves Comunicações',
|
||||
durationSeconds: 252,
|
||||
summary: 'Saúde e educação em Feira de Santana.',
|
||||
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
|
||||
officialTextUrl: null,
|
||||
keywords: ['SUS'],
|
||||
eventId: 67091,
|
||||
eventType: 'Sessão Deliberativa',
|
||||
eventStartAt: '2023-02-07T15:00',
|
||||
eventEndAt: '2023-02-07T21:23',
|
||||
youtubeUrl: null,
|
||||
presidingOfficer: 'Pompeo de Mattos',
|
||||
audioId: 558641,
|
||||
excerptTMs: 1675801808560,
|
||||
vodPlaybackUrl: null,
|
||||
vodDownloadUrl: null,
|
||||
facets: {
|
||||
topics: ['saude'],
|
||||
scopes: ['bahia'],
|
||||
municipalities: matchMunicipalityMentions('Feira de Santana'),
|
||||
people: ['Lula'],
|
||||
programs: [],
|
||||
projects: [],
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
segments: [{ startSeconds: 0, endSeconds: 2.7, text: 'A saúde pública baiana' }],
|
||||
...overrides,
|
||||
})
|
||||
220
tests/int/speechAcervo.int.spec.ts
Normal file
220
tests/int/speechAcervo.int.spec.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
|
||||
import config from '@/payload.config'
|
||||
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
|
||||
import {
|
||||
loadSpeechAcervoPageData,
|
||||
loadSpeechDetailPageData,
|
||||
SpeechNotFoundError,
|
||||
} from '@/utilities/speech/speechPageData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { speechBundleFixture } from '../helpers/speechBundleFixture'
|
||||
|
||||
let payload: Payload
|
||||
const createdSourceKeys = new Set<string>()
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const feiraDeSantana = () => matchMunicipalityMentions('Feira de Santana')
|
||||
|
||||
const feiraDeSantanaId = async (): Promise<number> => {
|
||||
const found = await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { slug: { equals: 'feira-de-santana' } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const municipality = found.docs[0]
|
||||
if (!municipality) throw new Error('seeded municipality missing')
|
||||
return municipality.id
|
||||
}
|
||||
|
||||
const baseBundle = (overrides: Partial<SpeechImportBundle> = {}): SpeechImportBundle => {
|
||||
const bundle = speechBundleFixture(overrides)
|
||||
createdSourceKeys.add(bundle.sourceKey)
|
||||
return bundle
|
||||
}
|
||||
|
||||
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
|
||||
const bundle = baseBundle(overrides)
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
const found = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: bundle.sourceKey } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const speech = found.docs[0]
|
||||
if (!speech) throw new Error('fixture speech was not created')
|
||||
return speech.id
|
||||
}
|
||||
|
||||
const createUsers = async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
|
||||
fixtures.createCampaignUser('communicator'),
|
||||
fixtures.createCampaignUser('coordinator'),
|
||||
fixtures.createCampaignUser('candidate'),
|
||||
fixtures.createCampaignUser('advisor'),
|
||||
fixtures.createCampaignUser('leader'),
|
||||
])
|
||||
return { communicator, coordinator, candidate, advisor, leader }
|
||||
}
|
||||
|
||||
describe('speech acervo (C154)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const sourceKey of createdSourceKeys) {
|
||||
const found = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: sourceKey } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const speech = found.docs[0]
|
||||
if (speech) {
|
||||
await payload.delete({
|
||||
collection: 'speech',
|
||||
id: speech.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('searches the segments accent-insensitively and paginates by speech', async () => {
|
||||
const runId = randomUUID().slice(0, 8)
|
||||
const marker = `zebra${runId}`
|
||||
const first = await createSpeech({
|
||||
segments: [
|
||||
{ startSeconds: 0, endSeconds: 3, text: `A defesa da saúde ${marker}` },
|
||||
{ startSeconds: 3, endSeconds: 6, text: `Mais saúde ${marker} para a Bahia` },
|
||||
],
|
||||
})
|
||||
const second = await createSpeech({
|
||||
segments: [{ startSeconds: 0, endSeconds: 3, text: `Saúde ${marker} no interior` }],
|
||||
})
|
||||
|
||||
const { coordinator } = await createUsers()
|
||||
const data = await loadSpeechAcervoPageData(payload, coordinator, {
|
||||
q: `saude ${marker}`,
|
||||
})
|
||||
|
||||
// Three matching segments, two speeches: the count is per speech.
|
||||
expect(data.totalDocs).toBe(2)
|
||||
expect(data.rows.map((row) => row.id).sort()).toEqual([first, second].sort())
|
||||
expect(data.rows.every((row) => row.matchKind === 'segment')).toBe(true)
|
||||
const excerptText = data.rows
|
||||
.flatMap((row) => row.excerpt.parts)
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
expect(excerptText).toContain(marker)
|
||||
})
|
||||
|
||||
it('matches an official keyword as an exact term', async () => {
|
||||
const runId = randomUUID().slice(0, 8)
|
||||
const id = await createSpeech({
|
||||
keywords: [`Farmácia Popular ${runId}`],
|
||||
segments: [{ startSeconds: 0, endSeconds: 2, text: 'Discurso sem o termo buscado' }],
|
||||
})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const data = await loadSpeechAcervoPageData(payload, communicator, {
|
||||
q: `Farmácia Popular ${runId}`,
|
||||
})
|
||||
|
||||
const row = data.rows.find((item) => item.id === id)
|
||||
expect(row?.matchKind).toBe('keyword')
|
||||
})
|
||||
|
||||
it('filters by topic, year, duration and cited municipality', async () => {
|
||||
const id = await createSpeech({
|
||||
speechAt: '2019-05-10T15:00',
|
||||
year: 2019,
|
||||
durationSeconds: 90,
|
||||
facets: {
|
||||
topics: ['igualdade-racial'],
|
||||
scopes: ['brasil'],
|
||||
municipalities: feiraDeSantana(),
|
||||
people: [],
|
||||
programs: [],
|
||||
projects: [],
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const municipalityId = await feiraDeSantanaId()
|
||||
|
||||
const data = await loadSpeechAcervoPageData(payload, communicator, {
|
||||
topic: 'igualdade-racial',
|
||||
year: '2019',
|
||||
duration: 'curta',
|
||||
municipality: String(municipalityId),
|
||||
})
|
||||
|
||||
expect(data.rows.map((row) => row.id)).toContain(id)
|
||||
expect(data.filterOptions.years).toContain(2019)
|
||||
})
|
||||
|
||||
it('returns the detail with segments, cited municipalities and the seek offset', async () => {
|
||||
const id = await createSpeech({
|
||||
segments: [
|
||||
{ startSeconds: 0, endSeconds: 3, text: 'Primeiro trecho' },
|
||||
{ startSeconds: 3, endSeconds: 8, text: 'O trecho sobre a saúde' },
|
||||
],
|
||||
})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const view = await loadSpeechDetailPageData(payload, communicator, id)
|
||||
|
||||
expect(view.id).toBe(id)
|
||||
expect(view.segments).toHaveLength(2)
|
||||
expect(view.segments[1]?.startSeconds).toBe(3)
|
||||
expect(view.municipalities.map((municipality) => municipality.name)).toContain(
|
||||
'Feira de Santana',
|
||||
)
|
||||
expect(view.presidingOfficer).toBe('Pompeo de Mattos')
|
||||
})
|
||||
|
||||
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
|
||||
const id = await createSpeech()
|
||||
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()
|
||||
|
||||
for (const reader of [communicator, coordinator, candidate]) {
|
||||
const data = await loadSpeechAcervoPageData(payload, reader, {})
|
||||
expect(data.rows.length).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
for (const denied of [advisor, leader]) {
|
||||
await expect(loadSpeechAcervoPageData(payload, denied, {})).rejects.toThrow(/permissão/i)
|
||||
await expect(loadSpeechDetailPageData(payload, denied, id)).rejects.toThrow(/permissão/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws the named not-found error for a missing speech', async () => {
|
||||
const { communicator } = await createUsers()
|
||||
await expect(
|
||||
loadSpeechDetailPageData(payload, communicator, 999_999_999),
|
||||
).rejects.toBeInstanceOf(SpeechNotFoundError)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
|
@ -14,46 +12,15 @@ import {
|
|||
type SpeechImportBundle,
|
||||
} from '@/utilities/speech/speechImport'
|
||||
|
||||
import { speechBundleFixture } from '../helpers/speechBundleFixture'
|
||||
|
||||
let payload: Payload
|
||||
const createdSourceKeys = new Set<string>()
|
||||
|
||||
const baseBundle = (overrides: Partial<SpeechImportBundle> = {}): SpeechImportBundle => {
|
||||
const sourceKey = `test-import-${randomUUID()}`
|
||||
createdSourceKeys.add(sourceKey)
|
||||
return {
|
||||
sourceKey,
|
||||
speechAt: '2023-02-07T17:28',
|
||||
year: 2023,
|
||||
legislature: '57',
|
||||
type: 'BREVES COMUNICAÇÕES',
|
||||
phase: 'Breves Comunicações',
|
||||
durationSeconds: 252,
|
||||
summary: 'Saúde e educação em Feira de Santana.',
|
||||
officialTranscript: 'O SR. JORGE SOLLA (Bloco/PT - BA) - Sr. Presidente...',
|
||||
officialTextUrl: null,
|
||||
keywords: ['SUS'],
|
||||
eventId: 67091,
|
||||
eventType: 'Sessão Deliberativa',
|
||||
eventStartAt: '2023-02-07T15:00',
|
||||
eventEndAt: '2023-02-07T21:23',
|
||||
youtubeUrl: null,
|
||||
presidingOfficer: 'Pompeo de Mattos',
|
||||
audioId: 558641,
|
||||
excerptTMs: 1675801808560,
|
||||
vodPlaybackUrl: null,
|
||||
vodDownloadUrl: null,
|
||||
facets: {
|
||||
topics: ['saude'],
|
||||
scopes: ['bahia'],
|
||||
municipalities: matchMunicipalityMentions('Feira de Santana'),
|
||||
people: ['Lula'],
|
||||
programs: [],
|
||||
projects: [],
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
segments: [{ startSeconds: 0, endSeconds: 2.7, text: 'A saúde pública baiana' }],
|
||||
...overrides,
|
||||
}
|
||||
const bundle = speechBundleFixture(overrides)
|
||||
createdSourceKeys.add(bundle.sourceKey)
|
||||
return bundle
|
||||
}
|
||||
|
||||
const findBySourceKey = async (sourceKey: string) => {
|
||||
|
|
@ -118,6 +85,26 @@ describe('upsertSpeechBundle (C153)', () => {
|
|||
expect(await countSegments(again?.id ?? 0)).toBe(1)
|
||||
})
|
||||
|
||||
it('mirrors the segments into the speech searchText and preserves it when skipped', async () => {
|
||||
const bundle = baseBundle({
|
||||
segments: [
|
||||
{ startSeconds: 0, endSeconds: 2, text: 'A saúde pública baiana' },
|
||||
{ startSeconds: 2, endSeconds: 5, text: 'Educação em Feira de Santana' },
|
||||
],
|
||||
})
|
||||
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
const speech = await findBySourceKey(bundle.sourceKey)
|
||||
expect(speech?.searchText).toBe('a saude publica baiana educacao em feira de santana')
|
||||
|
||||
await upsertSpeechBundle(
|
||||
payload,
|
||||
baseBundle({ sourceKey: bundle.sourceKey, segments: undefined }),
|
||||
)
|
||||
const preserved = await findBySourceKey(bundle.sourceKey)
|
||||
expect(preserved?.searchText).toBe('a saude publica baiana educacao em feira de santana')
|
||||
})
|
||||
|
||||
it('preserves manual facets and skips facet writes', async () => {
|
||||
const bundle = baseBundle()
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
|
|
|
|||
|
|
@ -288,12 +288,19 @@ describe('speechContentHash', () => {
|
|||
})
|
||||
|
||||
describe('parseOfficialKeywords', () => {
|
||||
it('splits the raw newline string and keeps each keyword', () => {
|
||||
it('splits newline-separated groups and comma-separated keywords', () => {
|
||||
expect(parseOfficialKeywords('Saúde, SUS\r\nEducação\r\n\r\nEscola')).toEqual([
|
||||
'Saúde, SUS',
|
||||
'Saúde',
|
||||
'SUS',
|
||||
'Educação',
|
||||
'Escola',
|
||||
])
|
||||
// Live Câmara shape: one group per line, keywords joined by commas.
|
||||
expect(parseOfficialKeywords('Governo federal,reconstrução,Política pública')).toEqual([
|
||||
'Governo federal',
|
||||
'reconstrução',
|
||||
'Política pública',
|
||||
])
|
||||
expect(parseOfficialKeywords(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import {
|
|||
getCampaignOverflowNav,
|
||||
isCampaignNavActive,
|
||||
} from '@/components/campaign/shell/nav'
|
||||
import { CAMPAIGN_AGENDA_HOME, CAMPAIGN_UPDATES_HREF } from '@/lib/campaignPaths'
|
||||
import {
|
||||
CAMPAIGN_AGENDA_HOME,
|
||||
CAMPAIGN_COMMUNICATION_HOME,
|
||||
CAMPAIGN_UPDATES_HREF,
|
||||
} from '@/lib/campaignPaths'
|
||||
import { ORGANIZATIONS_LIST_PATH } from '@/lib/campaignQuickActionPaths'
|
||||
|
||||
describe('organizations sidebar entry', () => {
|
||||
|
|
@ -59,11 +63,35 @@ describe('agenda sidebar entry', () => {
|
|||
})
|
||||
|
||||
describe('communicator sidebar', () => {
|
||||
it('has no staff destinations before the C154 vertical lands', () => {
|
||||
expect(getCampaignNav('communicator')).toEqual([])
|
||||
it('offers only the communication vertical, with no staff destinations', () => {
|
||||
const items = getCampaignNav('communicator')
|
||||
expect(items.map((item) => item.href)).toEqual([CAMPAIGN_COMMUNICATION_HOME])
|
||||
expect(items[0]?.title).toBe('Comunicação')
|
||||
expect(getCampaignBottomNav('communicator')).toHaveLength(0)
|
||||
expect(getCampaignOverflowNav('communicator')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is offered to coordinator/candidate and hidden from advisor/leader', () => {
|
||||
for (const role of ['coordinator', 'candidate'] as const) {
|
||||
expect(getCampaignNav(role).map((item) => item.href)).toContain(CAMPAIGN_COMMUNICATION_HOME)
|
||||
}
|
||||
expect(getCampaignNav('advisor').map((item) => item.href)).not.toContain(
|
||||
CAMPAIGN_COMMUNICATION_HOME,
|
||||
)
|
||||
expect(getCampaignNav('leader').map((item) => item.href)).not.toContain(
|
||||
CAMPAIGN_COMMUNICATION_HOME,
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the acervo and detail as active on the vertical item', () => {
|
||||
expect(isCampaignNavActive(CAMPAIGN_COMMUNICATION_HOME, CAMPAIGN_COMMUNICATION_HOME)).toBe(true)
|
||||
expect(isCampaignNavActive('/campanha/comunicacao/acervo', CAMPAIGN_COMMUNICATION_HOME)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
isCampaignNavActive('/campanha/comunicacao/acervo/42', CAMPAIGN_COMMUNICATION_HOME),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile bottom nav', () => {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,17 @@ describe('campaignPageChrome', () => {
|
|||
expect(campaignPageMetadata(null).title).toBe('Início')
|
||||
})
|
||||
|
||||
it('resolves the communication vertical and its acervo (C154)', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha/comunicacao', 'communicator')).toEqual({
|
||||
title: 'Comunicação',
|
||||
})
|
||||
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo', 'communicator')).toEqual({
|
||||
title: 'Acervo de falas',
|
||||
subtitle: 'Discursos do Deputado Jorge Solla na Câmara.',
|
||||
})
|
||||
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo/42', 'communicator')).toBeNull()
|
||||
})
|
||||
|
||||
it('separates staff contatos from the leader meus-contatos (C139)', () => {
|
||||
expect(resolveCampaignPageChrome('/campanha/contatos', 'coordinator')).toEqual({
|
||||
title: 'Contatos',
|
||||
|
|
|
|||
76
tests/unit/speechHighlight.unit.spec.ts
Normal file
76
tests/unit/speechHighlight.unit.spec.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildHighlightedExcerpt,
|
||||
findHighlightRanges,
|
||||
matchesSearchTerms,
|
||||
splitHighlightedParts,
|
||||
} from '@/lib/speechHighlight'
|
||||
|
||||
const highlighted = (text: string, query: string): string[] =>
|
||||
splitHighlightedParts(text, query)
|
||||
.filter((part) => part.highlighted)
|
||||
.map((part) => part.text)
|
||||
|
||||
describe('findHighlightRanges', () => {
|
||||
it('maps an accent-free query back to the accented original spelling', () => {
|
||||
const text = 'A defesa do SUS e da saúde pública'
|
||||
expect(findHighlightRanges(text, 'saude')).toEqual([{ start: 21, end: 26 }])
|
||||
expect(highlighted(text, 'saude')).toEqual(['saúde'])
|
||||
})
|
||||
|
||||
it('finds every term of a multi-word query, accent-insensitively', () => {
|
||||
const text = 'O Governo retomou a Farmácia Popular na Bahia'
|
||||
expect(highlighted(text, 'farmacia popular')).toEqual(['Farmácia', 'Popular'])
|
||||
expect(highlighted(text, 'FARMÁCIA')).toEqual(['Farmácia'])
|
||||
})
|
||||
|
||||
it('merges overlapping occurrences', () => {
|
||||
expect(findHighlightRanges('banana', 'ana')).toEqual([{ start: 1, end: 6 }])
|
||||
})
|
||||
|
||||
it('handles whitespace and punctuation around the match', () => {
|
||||
const text = ' saúde,\n educação '
|
||||
expect(highlighted(text, 'saude educacao')).toEqual(['saúde', 'educação'])
|
||||
})
|
||||
|
||||
it('returns nothing for an empty query or a miss', () => {
|
||||
expect(findHighlightRanges('texto', '')).toEqual([])
|
||||
expect(findHighlightRanges('texto', 'inexistente')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesSearchTerms', () => {
|
||||
it('requires every term, in any order', () => {
|
||||
expect(matchesSearchTerms('A defesa do SUS e da saúde', 'saude sus')).toBe(true)
|
||||
expect(matchesSearchTerms('A defesa do SUS', 'saude educacao')).toBe(false)
|
||||
expect(matchesSearchTerms('A defesa do SUS', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHighlightedExcerpt', () => {
|
||||
it('windows around the first match and flags truncation', () => {
|
||||
const text = `${'a'.repeat(300)} saúde ${'b'.repeat(300)}`
|
||||
const excerpt = buildHighlightedExcerpt(text, 'saude', { radius: 20 })
|
||||
|
||||
expect(excerpt.truncatedStart).toBe(true)
|
||||
expect(excerpt.truncatedEnd).toBe(true)
|
||||
const parts = excerpt.parts
|
||||
expect(parts.some((part) => part.highlighted && part.text === 'saúde')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the text start without highlights when nothing matches', () => {
|
||||
const excerpt = buildHighlightedExcerpt('Uma fala sem o termo buscado', 'inexistente', {
|
||||
radius: 10,
|
||||
})
|
||||
expect(excerpt.parts.every((part) => !part.highlighted)).toBe(true)
|
||||
expect(excerpt.truncatedStart).toBe(false)
|
||||
expect(excerpt.truncatedEnd).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty parts for empty text', () => {
|
||||
expect(buildHighlightedExcerpt(' ', 'saude').parts).toEqual([])
|
||||
})
|
||||
})
|
||||
78
tests/unit/speechListFilters.unit.spec.ts
Normal file
78
tests/unit/speechListFilters.unit.spec.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildSpeechListWhere } from '@/utilities/speech/speechListFilters'
|
||||
import { parseSpeechListParams } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
describe('buildSpeechListWhere', () => {
|
||||
it('is empty without filters', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({}))).toEqual({})
|
||||
})
|
||||
|
||||
it('assembles the facet filters', () => {
|
||||
const where = buildSpeechListWhere(
|
||||
parseSpeechListParams({
|
||||
year: '2026',
|
||||
topic: ['saude'],
|
||||
scope: ['bahia'],
|
||||
phase: ['Breves Comunicações'],
|
||||
municipality: '12',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
{ year: { in: [2026] } },
|
||||
{ topics: { in: ['saude'] } },
|
||||
{ scopes: { in: ['bahia'] } },
|
||||
{ phase: { in: ['Breves Comunicações'] } },
|
||||
{ mentionedMunicipalities: { in: [12] } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('searches the normalized speech text and the raw official keywords', () => {
|
||||
const where = buildSpeechListWhere(parseSpeechListParams({ q: 'Farmácia Popular' }))
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
{
|
||||
or: [
|
||||
{ searchText: { like: 'farmacia popular' } },
|
||||
{ keywords: { contains: 'Farmácia Popular' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('maps each duration bucket to its range and ORs them', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: 'curta' }))).toEqual({
|
||||
and: [{ durationSeconds: { less_than: 120 } }],
|
||||
})
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['media'] }))).toEqual({
|
||||
and: [
|
||||
{
|
||||
durationSeconds: { greater_than_equal: 120, less_than: 300 },
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['longa'] }))).toEqual({
|
||||
and: [{ durationSeconds: { greater_than_equal: 300 } }],
|
||||
})
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['sem_duracao'] }))).toEqual({
|
||||
and: [{ durationSeconds: { exists: false } }],
|
||||
})
|
||||
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['curta', 'longa'] }))).toEqual({
|
||||
and: [
|
||||
{
|
||||
or: [
|
||||
{ durationSeconds: { less_than: 120 } },
|
||||
{ durationSeconds: { greater_than_equal: 300 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
117
tests/unit/speechListUrl.unit.spec.ts
Normal file
117
tests/unit/speechListUrl.unit.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
import {
|
||||
buildSpeechFiltersKey,
|
||||
buildSpeechListHref,
|
||||
parseSpeechListParams,
|
||||
resolveSpeechListUrl,
|
||||
serializeCanonicalSpeechListSearchParams,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
|
||||
describe('parseSpeechListParams', () => {
|
||||
it('parses the canonical facets and keeps the page', () => {
|
||||
const state = parseSpeechListParams({
|
||||
q: 'Farmácia Popular',
|
||||
year: ['2026', '2025'],
|
||||
topic: ['saude', 'educacao'],
|
||||
scope: ['bahia'],
|
||||
phase: ['Breves Comunicações'],
|
||||
municipality: ['12', '999999'],
|
||||
duration: ['curta', 'longa'],
|
||||
page: '3',
|
||||
})
|
||||
|
||||
expect(state).toEqual({
|
||||
page: 3,
|
||||
q: 'Farmácia Popular',
|
||||
years: [2026, 2025],
|
||||
topics: ['saude', 'educacao'],
|
||||
scopes: ['bahia'],
|
||||
phases: ['Breves Comunicações'],
|
||||
municipalities: [12, 999999],
|
||||
durations: ['curta', 'longa'],
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses an exhaustive enum to "no filter"', () => {
|
||||
const state = parseSpeechListParams({
|
||||
topic: SPEECH_TOPICS.map(({ value }) => value),
|
||||
scope: SPEECH_SCOPES.map(({ value }) => value),
|
||||
})
|
||||
expect(state.topics).toBeUndefined()
|
||||
expect(state.scopes).toBeUndefined()
|
||||
})
|
||||
|
||||
it('validates data-driven params structurally', () => {
|
||||
const state = parseSpeechListParams({
|
||||
year: ['99', '2026', '0000'],
|
||||
municipality: ['0', '-4', '7'],
|
||||
phase: ['x'.repeat(81), 'Plenário'],
|
||||
q: 'a',
|
||||
page: '0',
|
||||
})
|
||||
|
||||
expect(state.years).toEqual([2026])
|
||||
expect(state.municipalities).toEqual([7])
|
||||
expect(state.phases).toEqual(['Plenário'])
|
||||
// One-character search is not ready — the same rule as contact search.
|
||||
expect(state.q).toBeUndefined()
|
||||
expect(state.page).toBe(1)
|
||||
})
|
||||
|
||||
it('drops unknown enum tokens', () => {
|
||||
const state = parseSpeechListParams({ topic: 'inexistente', duration: 'maratona' })
|
||||
expect(state.topics).toBeUndefined()
|
||||
expect(state.durations).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeCanonicalSpeechListSearchParams', () => {
|
||||
it('canonicalizes through parse and omits defaults', () => {
|
||||
const params = serializeCanonicalSpeechListSearchParams(
|
||||
parseSpeechListParams({ topic: ['saude'], page: '1' }),
|
||||
)
|
||||
expect(params.toString()).toBe('topic=saude')
|
||||
})
|
||||
|
||||
it('keeps every value of a multi-select facet', () => {
|
||||
const params = serializeCanonicalSpeechListSearchParams(
|
||||
parseSpeechListParams({ topic: ['saude', 'educacao'], duration: ['curta'] }),
|
||||
)
|
||||
expect(params.getAll('topic')).toEqual(['saude', 'educacao'])
|
||||
expect(params.getAll('duration')).toEqual(['curta'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSpeechListHref', () => {
|
||||
it('points at the acervo and clamps the page into the query', () => {
|
||||
expect(buildSpeechListHref(parseSpeechListParams({ q: 'SUS' }), 2)).toBe(
|
||||
'/campanha/comunicacao/acervo?q=SUS&page=2',
|
||||
)
|
||||
expect(buildSpeechListHref(parseSpeechListParams({}), 1)).toBe('/campanha/comunicacao/acervo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSpeechListUrl', () => {
|
||||
it('canonicalizes unsupported params into a redirect', () => {
|
||||
const resolved = resolveSpeechListUrl({ q: 'saude', desconhecido: 'x', page: '2' })
|
||||
expect(resolved.state.q).toBe('saude')
|
||||
expect(resolved.redirectHref).toBe('/campanha/comunicacao/acervo?q=saude&page=2')
|
||||
})
|
||||
|
||||
it('clamps a page beyond the total', () => {
|
||||
const resolved = resolveSpeechListUrl({ q: 'saude', page: '9' }, 2)
|
||||
expect(resolved.state.page).toBe(2)
|
||||
expect(resolved.redirectHref).toContain('page=2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSpeechFiltersKey', () => {
|
||||
it('is empty without filters and stable with them', () => {
|
||||
expect(buildSpeechFiltersKey(parseSpeechListParams({}))).toBe('')
|
||||
expect(buildSpeechFiltersKey(parseSpeechListParams({ topic: ['saude'] }))).toBe('topic=saude')
|
||||
})
|
||||
})
|
||||
112
tests/unit/speechOmnibox.unit.spec.ts
Normal file
112
tests/unit/speechOmnibox.unit.spec.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseSpeechListParams, type SpeechListState } from '@/utilities/speech/speechListUrl'
|
||||
import {
|
||||
applySpeechOmniboxSuggestion,
|
||||
buildSpeechOmniboxChips,
|
||||
clearSpeechOmnibox,
|
||||
removeSpeechOmniboxChip,
|
||||
} from '@/utilities/speech/speechOmnibox'
|
||||
|
||||
const state = (params: Record<string, string | string[]>): SpeechListState =>
|
||||
parseSpeechListParams(params)
|
||||
|
||||
describe('buildSpeechOmniboxChips', () => {
|
||||
it('labels every active facet', () => {
|
||||
const chips = buildSpeechOmniboxChips({
|
||||
state: state({
|
||||
q: 'Farmácia Popular',
|
||||
year: '2026',
|
||||
topic: ['saude'],
|
||||
scope: ['bahia'],
|
||||
phase: ['Breves Comunicações'],
|
||||
municipality: '12',
|
||||
duration: ['curta'],
|
||||
}),
|
||||
municipalityLabelsById: new Map([[12, 'Camaçari']]),
|
||||
})
|
||||
|
||||
expect(chips.map((chip) => chip.label)).toEqual([
|
||||
'Busca: Farmácia Popular',
|
||||
'Ano: 2026',
|
||||
'Tema: Saúde',
|
||||
'Alcance: Bahia',
|
||||
'Fase: Breves Comunicações',
|
||||
'Município: Camaçari',
|
||||
'Duração: Até 2 min',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applySpeechOmniboxSuggestion', () => {
|
||||
it('sets the search query and resets the page', () => {
|
||||
const action = applySpeechOmniboxSuggestion({
|
||||
state: state({ page: '4', topic: ['saude'] }),
|
||||
suggestionId: 'q:SUS',
|
||||
})
|
||||
expect(action).toEqual({
|
||||
kind: 'url',
|
||||
state: { page: 1, q: 'SUS', topics: ['saude'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles a facet value on and off', () => {
|
||||
const on = applySpeechOmniboxSuggestion({
|
||||
state: state({ topic: ['saude'] }),
|
||||
suggestionId: 'topic:educacao',
|
||||
})
|
||||
expect(on.state.topics).toEqual(['saude', 'educacao'])
|
||||
|
||||
const off = applySpeechOmniboxSuggestion({
|
||||
state: state({ topic: ['saude', 'educacao'] }),
|
||||
suggestionId: 'topic:saude',
|
||||
})
|
||||
expect(off.state.topics).toEqual(['educacao'])
|
||||
})
|
||||
|
||||
it('toggles numeric facets', () => {
|
||||
const action = applySpeechOmniboxSuggestion({
|
||||
state: state({ municipality: '12' }),
|
||||
suggestionId: 'municipality:12',
|
||||
})
|
||||
expect(action.state.municipalities).toBeUndefined()
|
||||
|
||||
const years = applySpeechOmniboxSuggestion({
|
||||
state: state({}),
|
||||
suggestionId: 'year:2026',
|
||||
})
|
||||
expect(years.state.years).toEqual([2026])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSpeechOmniboxChip', () => {
|
||||
it('removes a single facet value and clears the field when it was the last', () => {
|
||||
const kept = removeSpeechOmniboxChip({
|
||||
state: state({ topic: ['saude', 'educacao'] }),
|
||||
chipId: 'topic:saude',
|
||||
})
|
||||
expect(kept.state.topics).toEqual(['educacao'])
|
||||
|
||||
const emptied = removeSpeechOmniboxChip({
|
||||
state: state({ topic: ['saude'] }),
|
||||
chipId: 'topic:saude',
|
||||
})
|
||||
expect(emptied.state.topics).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes the search chip', () => {
|
||||
const action = removeSpeechOmniboxChip({ state: state({ q: 'SUS' }), chipId: 'q' })
|
||||
expect(action.state.q).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSpeechOmnibox', () => {
|
||||
it('resets every filter', () => {
|
||||
expect(clearSpeechOmnibox(state({ q: 'SUS', topic: ['saude'] }))).toEqual({
|
||||
kind: 'clear',
|
||||
state: { page: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue