From 516a18b22506048cac7d67cd480f0b876ab72d31 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 16:17:30 -0300 Subject: [PATCH] refactor(C216): aplica achados do simplify (Related #1292) --- docs/plans/acervo-fonte-falas-web-impl.md | 13 ++ docs/plans/escala-dry-pos-c216.md | 59 ++++++ .../acervo/internet/[id]/arquivo/route.ts | 26 +-- .../acervo/internet/[id]/capa/route.ts | 19 +- .../(app)/comunicacao/acervo/page.tsx | 199 +++++++++--------- .../campaign/shared/AcervoSortSelect.tsx | 25 +-- .../CampaignTranscriptSegmentButton.tsx | 2 +- .../campaign/speech/WebSpeechPlatformPill.tsx | 3 - src/lib/acervoSource.ts | 2 +- src/utilities/speech/speechListFilters.ts | 15 +- src/utilities/speech/speechListUrl.ts | 6 +- src/utilities/speech/speechOmnibox.ts | 2 +- src/utilities/speech/speechPageData.ts | 67 +++--- src/utilities/speech/speechViewModels.ts | 26 ++- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 14 +- tests/unit/speechListUrl.unit.spec.ts | 8 - 16 files changed, 267 insertions(+), 219 deletions(-) create mode 100644 docs/plans/escala-dry-pos-c216.md diff --git a/docs/plans/acervo-fonte-falas-web-impl.md b/docs/plans/acervo-fonte-falas-web-impl.md index 7e029354..a54b471a 100644 --- a/docs/plans/acervo-fonte-falas-web-impl.md +++ b/docs/plans/acervo-fonte-falas-web-impl.md @@ -210,6 +210,19 @@ Quota: ~3–4 dias eng, dentro do appetite herdado. Se apertar, os cortes são o - [ ] Design: artefato estendido pelo `designer` (áudio + chips Instagram/Áudio) antes do markup e crítica final (trigger c) registrada como `Design tier` no PR; `DEGRADED` ⇒ para antes do push (em `--auto`: sem PR, Issue `blocked`). - [ ] Gates: `tsc --noEmit`, `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`, `pnpm test:e2e:affected`; `pnpm push`; entrada em `docs/changelog/`. +## Simplify — triage dos revisores (2026-09-24) + +Aplicados na sessão (não reabrir): extração de `withSeekQuery` (os dois hrefs de detalhe), inline de `acervoSortIsDuration` no gate de duração (wrapper `webSpeechSortIsDuration` removido), remoção de exports sem consumidor (`AcervoSortState`, `RawAcervoSourceParams`, `TranscriptSegment`, `className` do pill, `externalId` morto no select, `as const` redundante), `loadWebSpeechMediaForActor` compartilhado pelas rotas `arquivo`/`capa`, reuso de `loadSegmentsForSpeeches` no detalhe web, hoist de `webSourceParams`, `speechHasActiveFilters` nos dois ramos da página, extração de `CamaraSource` (gatilho do S5 do C219 vencido) e `WebSpeechesSource`; e2e com extensão coerente de áudio e sem asserts-isca. Também corrigido no e2e um bug real da fronteira server→client (o sort recebia `toHref` função): o `AcervoSortSelect` escolhe o serializador pelo `source` do estado dentro do client component. + +| ID | Resumo | Origem | Score | Tipo | Destino | +| --- | ------------------------------------------------------------------------------------------------ | ---------------- | ----- | -------------- | ----------------------------------------------------------------------------------------------- | +| F1 | Gate fail-closed das rotas de mídia privada copiado em 4–5 rotas (as duas novas já compartilham) | simplify/reuse | 4 | expensive_lock | registrar — `docs/plans/escala-dry-pos-c216.md` F1 (Issue nova `depends: [1292]`) | +| F2 | Scaffold de resultados repetido nos três ramos da página do acervo | simplify/reuse | 3 | cheap_polish | registrar — mesmo lote, F2 | +| F3 | Excerpt/chips duplicados entre `SpeechResultCard` e `WebSpeechResultCard` | simplify/reuse | 2 | cheap_polish | registrar — mesmo lote, F3 (gatilho antecipado: 3º card de fala) | +| D1 | `speechHasActiveFilters` × `recordingHasActiveFilters` (domínios diferentes) | simplify/reuse | 2 | defer_trigger | defer — gatilho: 3ª lista com predicado de filtros ativos ou divergência de param entre estados | +| D2 | Bytes MP4/MP3 de fixture copiados entre os int specs de fala web (2 sites) | simplify/reuse | 2 | defer_trigger | defer — gatilho: 3º spec de fala web precisar dos bytes | +| D3 | `firstParam` idêntico em `acervoSource`/`acervoListSort` (2 linhas) | simplify/quality | 1 | cheap_polish | descartar (camadas impedem dono comum; custa mais que a cópia) | + ## Self-score de decision-quality **4,5/5.** diff --git a/docs/plans/escala-dry-pos-c216.md b/docs/plans/escala-dry-pos-c216.md new file mode 100644 index 00000000..6ba5e254 --- /dev/null +++ b/docs/plans/escala-dry-pos-c216.md @@ -0,0 +1,59 @@ +# Escala/DRY pós-C216 — débitos do simplify da terceira fonte do acervo + +Status: rascunho +Atualizado em: 2026-09-24 +Issue: (a registrar — `depends: [1292]`) +Priority: P3 +Impeccable: A — só backend/shell (sem superfície visual nova) +Appetite: ~1 dia eng fill-in +Responsável: — +Depende de: #1292 (C216) + +## Contexto + +O `/simplify` do C216 (terceira fonte "Falas na internet") aplicou os cleanup da +sessão (extração de `withSeekQuery`, inline do gate de sort, remoção de exports +sem consumidor, `loadWebSpeechMediaForActor` nas duas rotas novas, extração do +ramo Câmara em `CamaraSource`) e deixou três débitos maiores que o cleanup, mais +os defer+gatilho registrados no `acervo-fonte-falas-web-impl.md` (§Simplify — +triage). Este lote consolida os três numa trilha ordenada (lock primeiro). + +## Fases + +1. **F1 — Gate fail-closed das rotas de mídia privada (expensive_lock).** + O gate `getCampaignUser` + predicado do catálogo + validação de id + + `findByID(...).catch(() => null)` + 404 silencioso está replicado nas rotas + `acervo/gravacoes/[id]/arquivo`, `reels/*`, `conteudos/*` e agora + `acervo/internet/[id]/arquivo|capa` (as duas novas já compartilham o loader + `loadWebSpeechMediaForActor`). Extrair um helper de rota no dono + `utilities/privateMedia/` (ex.: `loadPrivateMediaForActors`) que recebe a + collection, o predicado e o mapa de campos, devolvendo `null` para toda + negação — as rotas ficam com o HTTP (range/disposition/404) e o helper com o + gate. Int/e2e existentes (`campaignSpeechAcervo`, `campaignReel`, Central de + Conteúdos) são o pino; não mudar nenhuma resposta HTTP. +2. **F2 — Scaffold de resultados dos três ramos da página do acervo.** + `CamaraSource`/`RecordingsSource`/`WebSpeechesSource` repetem o heading do + modo tema, o `CampaignListEmptyState` com ternários e o `CampaignListFooter`. + Extrair um shell local (ou `shared/`) parametrizado por copy/links — sem + mudar bytes de nenhum dos ramos (o pin é o e2e `campaignSpeechAcervo`). +3. **F3 — Excerpt e chips dos cards de fala (cheap_polish).** + `SpeechResultCard` e `WebSpeechResultCard` duplicam o excerpt truncado + (`… …`) e o render de chips com caps 3/2 + `+N`. Extrair o excerpt + para o dono do highlight (`SpeechHighlightParts`) ou um componente irmão; + chips param por lista de grupos (a Câmara tem keywords, a web não). Gatilho + antecipado: um terceiro card de fala. + +## Rabbit holes / Não escopo + +- Não unificar `speechHasActiveFilters` × `recordingHasActiveFilters` (shapes + divergentes: `phases` × `people`; DRY <3) — defer com gatilho no impl do C216. +- Não centralizar os bytes de fixture MP4/MP3 dos int specs (2 sites) — defer. +- Não mexer em `speechCoverage`/Sollinha/C217. + +## Riscos + +- F1 toca access fail-closed: o helper precisa manter o 404 silencioso e o + `overrideAccess: false`; qualquer resposta diferente é regressão pinada por + int/e2e. +- F2/F3 são cosméticos de shell; o risco é bytes de copy — os specs da vertical + (C154/C199/C211/C216) são o pino. diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts index 70ad6e5b..48b50ced 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts @@ -10,6 +10,7 @@ import { buildPrivateMediaResponse, resolvePrivateMediaStaticDir, } from '@/utilities/privateMedia/privateMediaResponse' +import { loadWebSpeechMediaForActor } from '@/utilities/speech/speechPageData' export const dynamic = 'force-dynamic' @@ -40,30 +41,17 @@ export const GET = async ( if (!Number.isInteger(speechId) || speechId <= 0) return notFound() const payload = await getPayload({ config }) - const speech = await payload - .findByID({ - collection: 'speech', - id: speechId, - depth: 1, - select: { origin: true, title: true, mirroredMedia: true }, - user, - overrideAccess: false, - }) - .catch(() => null) - if (!speech || speech.origin !== 'web') return notFound() + const found = await loadWebSpeechMediaForActor(payload, user, speechId, 'mirroredMedia') + if (!found) return notFound() - const media = speech.mirroredMedia - if (!media || typeof media !== 'object') return notFound() - - const url = new URL(request.url) return buildPrivateMediaResponse({ - media, + media: found.media, staticDir: resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG), rangeHeader: request.headers.get('range'), - download: url.searchParams.get('download') === '1', + download: new URL(request.url).searchParams.get('download') === '1', dispositionFilename: webSpeechDownloadFilename({ - title: speech.title, - storedFilename: media.filename, + title: found.title, + storedFilename: found.media.filename, }), }) } diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/capa/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/capa/route.ts index 0ebe8c04..01a5ea44 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/capa/route.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/capa/route.ts @@ -10,6 +10,7 @@ import { buildPrivateMediaResponse, resolvePrivateMediaStaticDir, } from '@/utilities/privateMedia/privateMediaResponse' +import { loadWebSpeechMediaForActor } from '@/utilities/speech/speechPageData' export const dynamic = 'force-dynamic' @@ -38,23 +39,11 @@ export const GET = async ( if (!Number.isInteger(speechId) || speechId <= 0) return notFound() const payload = await getPayload({ config }) - const speech = await payload - .findByID({ - collection: 'speech', - id: speechId, - depth: 1, - select: { origin: true, thumbnail: true }, - user, - overrideAccess: false, - }) - .catch(() => null) - if (!speech || speech.origin !== 'web') return notFound() - - const thumbnail = speech.thumbnail - if (!thumbnail || typeof thumbnail !== 'object') return notFound() + const found = await loadWebSpeechMediaForActor(payload, user, speechId, 'thumbnail') + if (!found) return notFound() return buildPrivateMediaResponse({ - media: thumbnail, + media: found.media, staticDir: resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG), rangeHeader: request.headers.get('range'), download: false, diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx index bcc5eebc..c381fe20 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx @@ -206,7 +206,7 @@ const RecordingsSource = ({ * gestures. The source has no Fase facet and no upload action, so the bar takes * the web copy and the ordering control its own hint. */ -const WebSpeechSource = ({ +const WebSpeechesSource = ({ data, }: { data: Awaited> @@ -318,6 +318,107 @@ const WebSpeechSource = ({ ) } +/** + * C154 — the Câmara source: the original acervo list with its facets (Fase + * included). The three sources now share the page as one branch each. + */ +const CamaraSource = ({ data }: { data: Awaited> }) => { + const hasFilters = speechHasActiveFilters(data.state) + const themeMode = data.state.mode === 'tema' + const themeActive = themeMode && data.themeApplied && !data.themeUnavailable + + return ( + + + + {data.themeUnavailable ? : null} + + + {themeMode ? ( +
+
+

+ {data.themeUnavailable + ? 'Resultados por termo exato' + : themeActive + ? 'Resultados por tema' + : 'Resultados'} +

+

+ {data.themeUnavailable + ? 'Comportamento atual do acervo.' + : themeActive + ? 'Confira o indício em cada fala antes de abrir.' + : 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'} +

+
+ {data.themeUnavailable ? : null} +
+ ) : null} + + {data.rows.length > 0 ? ( + + ) : ( + + {themeActive ? ( +
+ + + +
+ ) : ( + + )} +
+ )} + + {data.rows.length > 0 ? ( + buildSpeechListHref(data.state, page)} + /> + ) : null} +
+
+ ) +} + export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPageProps) { const [user, payload, params] = await Promise.all([ requireCampaignPageActor({ gate: 'communicationCatalog' }), @@ -346,7 +447,7 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag return ( - + ) } @@ -354,102 +455,10 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag const data = await loadSpeechAcervoPageData(payload, user, params) if (data.redirectHref) redirect(data.redirectHref) - const hasFilters = buildSpeechFiltersKey(data.state) !== '' - const themeMode = data.state.mode === 'tema' - const themeActive = themeMode && data.themeApplied && !data.themeUnavailable - return ( - - - - - {data.themeUnavailable ? : null} - - - {themeMode ? ( -
-
-

- {data.themeUnavailable - ? 'Resultados por termo exato' - : themeActive - ? 'Resultados por tema' - : 'Resultados'} -

-

- {data.themeUnavailable - ? 'Comportamento atual do acervo.' - : themeActive - ? 'Confira o indício em cada fala antes de abrir.' - : 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'} -

-
- {data.themeUnavailable ? : null} -
- ) : null} - - {data.rows.length > 0 ? ( - - ) : ( - - {themeActive ? ( -
- - - -
- ) : ( - - )} -
- )} - - {data.rows.length > 0 ? ( - buildSpeechListHref(data.state, page)} - /> - ) : null} -
-
+
) } diff --git a/src/components/campaign/shared/AcervoSortSelect.tsx b/src/components/campaign/shared/AcervoSortSelect.tsx index 8a4abc97..9cb067bf 100644 --- a/src/components/campaign/shared/AcervoSortSelect.tsx +++ b/src/components/campaign/shared/AcervoSortSelect.tsx @@ -8,21 +8,17 @@ import { } from '@/utilities/recordings/recordingListUrl' import { buildSpeechListHref, type SpeechListState } from '@/utilities/speech/speechListUrl' -/** - * C216 — the list state of one acervo source. The `source` discriminator picks - * the canonical serializer inside this client component: a server component - * can never hand a function prop across the boundary, so the domain modules - * are imported here (they are client-safe pure contracts) and the page only - * passes the serializable state and its honest hint. - */ -export type AcervoSortState = RecordingListState | SpeechListState - /** * C216 — the shared acervo ordering control (design scene 01): "Mais recentes" - * (the default, never serialized) plus the two duration orders. Each source - * passes its own state and the honest line shown while a duration order is - * active (it only lists rows with a measured duration). + * (the default, never serialized) plus the two duration orders. The list state + * of one source carries the `source` discriminator that picks the canonical + * serializer here: a server component can never hand a function prop across + * the boundary, so the client-safe domain contracts are imported inside this + * component and the page passes only the serializable state and the honest + * line shown while a duration order is active (it lists measured rows only). */ +type AcervoSortState = RecordingListState | SpeechListState + export const AcervoSortSelect = ({ state, hint }: { state: AcervoSortState; hint: string }) => { const { navigate, isPending } = useCampaignListFilterNavigation({ state, @@ -31,10 +27,7 @@ export const AcervoSortSelect = ({ state, hint }: { state: AcervoSortState; hint }) const change = (value: AcervoSortKey) => { - const sort = value === 'recentes' ? undefined : value - navigate( - state.source === 'enviadas' ? { ...state, page: 1, sort } : { ...state, page: 1, sort }, - ) + navigate({ ...state, page: 1, sort: value === 'recentes' ? undefined : value }) } return ( diff --git a/src/components/campaign/shared/CampaignTranscriptSegmentButton.tsx b/src/components/campaign/shared/CampaignTranscriptSegmentButton.tsx index db6c1eda..84f3be0d 100644 --- a/src/components/campaign/shared/CampaignTranscriptSegmentButton.tsx +++ b/src/components/campaign/shared/CampaignTranscriptSegmentButton.tsx @@ -9,7 +9,7 @@ import { cn } from '@/lib/utils' * the Câmara/detail VMs of every acervo source (C200 recordings, C216 web * speeches) share one clickable segment without a twin. */ -export type TranscriptSegment = { +type TranscriptSegment = { startSeconds: number startLabel: string parts: readonly SpeechHighlightPart[] diff --git a/src/components/campaign/speech/WebSpeechPlatformPill.tsx b/src/components/campaign/speech/WebSpeechPlatformPill.tsx index 3aa0fef9..612adc86 100644 --- a/src/components/campaign/speech/WebSpeechPlatformPill.tsx +++ b/src/components/campaign/speech/WebSpeechPlatformPill.tsx @@ -16,17 +16,14 @@ const PLATFORM_CLASSES: Record = { export const WebSpeechPlatformPill = ({ platform, label, - className, }: { platform: WebSpeechPlatform | null label: string - className?: string }) => ( {label} diff --git a/src/lib/acervoSource.ts b/src/lib/acervoSource.ts index fc611267..8dc464cf 100644 --- a/src/lib/acervoSource.ts +++ b/src/lib/acervoSource.ts @@ -8,7 +8,7 @@ */ import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths' -export type RawAcervoSourceParams = Record +type RawAcervoSourceParams = Record export const ACERVO_SOURCE_PARAM = 'source' export const ACERVO_SOURCE_ENVIADAS = 'enviadas' diff --git a/src/utilities/speech/speechListFilters.ts b/src/utilities/speech/speechListFilters.ts index 382df6fb..d9234e1e 100644 --- a/src/utilities/speech/speechListFilters.ts +++ b/src/utilities/speech/speechListFilters.ts @@ -6,13 +6,10 @@ */ import type { Where } from 'payload' +import { acervoSortIsDuration } from '@/lib/acervoListSort' import { normalizeForSearch } from '@/lib/speechSearch' import { collapseListWhereOrBranches } from '@/utilities/campaignListUrl' -import { - webSpeechSortIsDuration, - type SpeechDurationBucket, - type SpeechListState, -} from '@/utilities/speech/speechListUrl' +import type { SpeechDurationBucket, SpeechListState } from '@/utilities/speech/speechListUrl' const DURATION_MEDIA_MIN_SECONDS = 120 const DURATION_LONGA_MIN_SECONDS = 300 @@ -57,10 +54,10 @@ const buildSpeechFacetWhere = (state: SpeechListState): Where[] => { const branch = collapseListWhereOrBranches(state.durations.map(durationBucketWhere)) if (branch) filters.push(branch) } - // C216 — a duration order only lists rows with a measured duration (Postgres - // sorts NULLS FIRST on DESC, so without the gate "Duração (maior)" would open - // with the rows that have no duration). - if (webSpeechSortIsDuration(state)) filters.push({ durationSeconds: { exists: true } }) + // C216 — a duration order (web-only: the Câmara contract has no sort) only + // lists rows with a measured duration; Postgres sorts NULLS FIRST on DESC, so + // without the gate "Duração (maior)" would open with the rows without one. + if (acervoSortIsDuration(state.sort)) filters.push({ durationSeconds: { exists: true } }) return filters } diff --git a/src/utilities/speech/speechListUrl.ts b/src/utilities/speech/speechListUrl.ts index 3931e892..f0fc4950 100644 --- a/src/utilities/speech/speechListUrl.ts +++ b/src/utilities/speech/speechListUrl.ts @@ -5,7 +5,7 @@ * member equals no filter); year/phase/municipality are data-driven and * validated structurally. */ -import { acervoSortIsDuration, parseAcervoSort, type AcervoSortKey } from '@/lib/acervoListSort' +import { parseAcervoSort, type AcervoSortKey } from '@/lib/acervoListSort' import { ACERVO_SOURCE_INTERNET, parseAcervoSource } from '@/lib/acervoSource' import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths' import { isContactSearchQueryReady } from '@/lib/contactSearchQuery' @@ -226,10 +226,6 @@ export const webSpeechSortOrder = (state: SpeechListState): string => { } } -/** C216 — the duration gate of the web list, mirroring the recordings source. */ -export const webSpeechSortIsDuration = (state: SpeechListState): boolean => - state.source === 'internet' && acervoSortIsDuration(state.sort) - /** * C216 — whether the actor narrowed the list (query/mode or any facet). The * empty state uses it to tell "nothing was ever catalogued" from "the filters diff --git a/src/utilities/speech/speechOmnibox.ts b/src/utilities/speech/speechOmnibox.ts index 2225e04a..e6168ad1 100644 --- a/src/utilities/speech/speechOmnibox.ts +++ b/src/utilities/speech/speechOmnibox.ts @@ -285,6 +285,6 @@ export const clearSpeechOmnibox = (state: SpeechListState): SpeechOmniboxAction kind: 'clear', state: { page: 1, - ...(state.source === 'internet' ? { source: 'internet' as const } : {}), + ...(state.source === 'internet' ? { source: 'internet' } : {}), }, }) diff --git a/src/utilities/speech/speechPageData.ts b/src/utilities/speech/speechPageData.ts index 5c1520e3..3a45bcdf 100644 --- a/src/utilities/speech/speechPageData.ts +++ b/src/utilities/speech/speechPageData.ts @@ -4,7 +4,7 @@ import type { Payload } from 'payload' import { canReadCommunicationCatalog } from '@/lib/campaignRoles' import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod' -import type { CampaignUser, Speech } from '@/payload-types' +import type { CampaignUser, InternetSpeechMedia, Speech } from '@/payload-types' import { expandSpeechSearchTheme, type SpeechThemeExpansionResolver, @@ -301,7 +301,6 @@ const webSpeechListSelect = { const webSpeechDetailSelect = { ...webSpeechListSelect, - externalId: true, sourceUrl: true, channel: true, mirroredMedia: true, @@ -334,10 +333,10 @@ export const loadWebSpeechAcervoPageData = async ( expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme, ): Promise => { const rawSearchParams = await searchParams - const canonicalUrl = resolveSpeechListUrl({ - ...rawSearchParams, - source: 'internet', - }) + // The source is forced (the page dispatches on `source=internet`): the + // canonical redirect always carries it and the loader is self-contained. + const webSourceParams = { ...rawSearchParams, source: 'internet' } + const canonicalUrl = resolveSpeechListUrl(webSourceParams) const state = canonicalUrl.state const { themeTerms, themeUnavailable } = await resolveSpeechThemeTerms({ @@ -360,10 +359,7 @@ export const loadWebSpeechAcervoPageData = async ( overrideAccess: false, }) - const resolvedUrl = resolveSpeechListUrl( - { ...rawSearchParams, source: 'internet' }, - result.totalPages, - ) + const resolvedUrl = resolveSpeechListUrl(webSourceParams, result.totalPages) const speeches = result.docs as WebSpeechListRecord[] const speechIds = speeches.map((speech) => speech.id) @@ -432,29 +428,48 @@ export const loadWebSpeechDetailPageData = async ( const speech = result.docs[0] if (!speech) throw new SpeechNotFoundError() - const segments = await payload.find({ - collection: 'speechSegment', - where: { speech: { equals: speechId } }, - depth: 0, - limit: 0, - pagination: false, - sort: 'order', - select: segmentSelect, - user, - overrideAccess: false, - }) + const segmentsBySpeech = await loadSegmentsForSpeeches(payload, user, [speechId]) return toWebSpeechDetailViewModel({ speech: speech as WebSpeechDetailRecord, - segments: segments.docs.map((segment) => ({ - startSeconds: segment.startSeconds, - endSeconds: segment.endSeconds, - text: segment.text, - })), + segments: segmentsBySpeech.get(speechId) ?? [], query, }) } +/** The two private upload relations one web speech serves through its routes. */ +export type WebSpeechMediaField = 'mirroredMedia' | 'thumbnail' + +/** + * C216 — the shared read of the two media routes (`/arquivo` and `/capa`): one + * `findByID` with the acervo gate and the origin filter, answering null for + * anything that is not a web row with that artifact. The routes keep the HTTP + * contract (silent 404, range, disposition) and the caller's auth gate. + */ +export const loadWebSpeechMediaForActor = async ( + payload: Payload, + user: CampaignUser, + speechId: number, + field: WebSpeechMediaField, +): Promise<{ title: string | null; media: InternetSpeechMedia } | null> => { + const speech = await payload + .findByID({ + collection: 'speech', + id: speechId, + depth: 1, + select: { origin: true, title: true, mirroredMedia: true, thumbnail: true }, + user, + overrideAccess: false, + }) + .catch(() => null) + if (!speech || speech.origin !== 'web') return null + + const media = field === 'mirroredMedia' ? speech.mirroredMedia : speech.thumbnail + if (!media || typeof media !== 'object') return null + + return { title: speech.title ?? null, media } +} + /** * Resolves the broadcast start (ISO) of a session video. The default reads the * cached YouTube anchor (C172); tests inject their own to stay offline. diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts index 823899ca..a8d69e98 100644 --- a/src/utilities/speech/speechViewModels.ts +++ b/src/utilities/speech/speechViewModels.ts @@ -221,19 +221,29 @@ const pickThemeMatch = ( return undefined } -export const buildWatchHref = ( - speechId: number, +/** + * Shared seek/query trailer of both detail hrefs (Câmara and web): ASR + * timestamps are fractional seconds, so the URL carries whole seconds, and the + * search term rides along for the transcript highlight. + */ +const withSeekQuery = ( + href: string, 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}` : ''}` + return queryString ? `${href}?${queryString}` : href } +export const buildWatchHref = ( + speechId: number, + segment: SpeechSegmentRecord | undefined, + query: string | undefined, +): string => withSeekQuery(`${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}`, segment, query) + type SpeechCuts = readonly SpeechCutSummaryViewModel[] const thumbnailUrlOf = (speech: SpeechListRecord): string | null => @@ -454,13 +464,7 @@ const buildWebSpeechWatchHref = ( speechId: number, segment: SpeechSegmentRecord | undefined, query: string | undefined, -): string => { - const params = new URLSearchParams() - if (segment) params.set('t', String(Math.max(0, Math.floor(segment.startSeconds)))) - if (query) params.set('q', query) - const queryString = params.toString() - return `${campaignInternetSpeechHref(speechId)}${queryString ? `?${queryString}` : ''}` -} +): string => withSeekQuery(campaignInternetSpeechHref(speechId), segment, query) /** * List item of one web speech: platform chip, publication date + duration, the diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts index 493b51a0..ff30bb1a 100644 --- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts +++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts @@ -1043,14 +1043,14 @@ test.describe('communication vertical (C154/C162)', () => { withThumbnail?: boolean }, ) => { + const isAudio = input.platform === 'radio' || input.platform === 'audio' const media = await campaign.fixtures.payload.create({ collection: 'internetSpeechMedia', data: { alt: `Mídia ${input.marker}` }, file: { data: Buffer.from(`mirror-${input.marker}`), - mimetype: - input.platform === 'radio' || input.platform === 'audio' ? 'audio/mpeg' : 'video/mp4', - name: `${input.marker}.mp4`, + mimetype: isAudio ? 'audio/mpeg' : 'video/mp4', + name: `${input.marker}.${isAudio ? 'mp3' : 'mp4'}`, size: Buffer.byteLength(`mirror-${input.marker}`), }, }) @@ -1113,7 +1113,7 @@ test.describe('communication vertical (C154/C162)', () => { }) => { const marker = campaign.fixtures.value('internet') const { speech } = await createWebSpeech(campaign, { marker, withThumbnail: true }) - const withoutCover = await createWebSpeech(campaign, { + await createWebSpeech(campaign, { marker: `${marker}capa`, platform: 'radio', withThumbnail: false, @@ -1166,7 +1166,6 @@ test.describe('communication vertical (C154/C162)', () => { const miss = await request.get('/campanha/comunicacao/acervo?source=internet&q=zzzznada') expect(rendered(await miss.text())).toContain('Nenhuma fala encontrada') - expect(withoutCover.speech.id).toBeGreaterThan(0) }) test('the filters narrow, order and degrade honestly (C219 parity)', async ({ @@ -1291,7 +1290,7 @@ test.describe('communication vertical (C154/C162)', () => { campaignRequest, }) => { const marker = campaign.fixtures.value('internetarquivo') - const { speech, media, thumbnail } = await createWebSpeech(campaign, { + const { speech } = await createWebSpeech(campaign, { marker, title: `Fala privada ${marker}`, withThumbnail: true, @@ -1356,9 +1355,6 @@ test.describe('communication vertical (C154/C162)', () => { ) ).status(), ).toBe(404) - - expect(media.id).toBeGreaterThan(0) - expect(thumbnail?.id).toBeGreaterThan(0) }) }) diff --git a/tests/unit/speechListUrl.unit.spec.ts b/tests/unit/speechListUrl.unit.spec.ts index 54108003..c2dd7f86 100644 --- a/tests/unit/speechListUrl.unit.spec.ts +++ b/tests/unit/speechListUrl.unit.spec.ts @@ -10,7 +10,6 @@ import { resolveSpeechListUrl, serializeCanonicalSpeechListSearchParams, speechHasActiveFilters, - webSpeechSortIsDuration, webSpeechSortOrder, } from '@/utilities/speech/speechListUrl' @@ -228,13 +227,6 @@ describe('web speeches source (C216)', () => { expect( webSpeechSortOrder(parseSpeechListParams({ source: 'internet', sort: 'duracao_menor' })), ).toBe('durationSeconds') - - expect(webSpeechSortIsDuration(parseSpeechListParams({ source: 'internet' }))).toBe(false) - expect( - webSpeechSortIsDuration(parseSpeechListParams({ source: 'internet', sort: 'duracao_maior' })), - ).toBe(true) - // The gate never applies to the Câmara state. - expect(webSpeechSortIsDuration(parseSpeechListParams({}))).toBe(false) }) it('tells an active filter from the source discriminator', () => {