refactor(C216): aplica achados do simplify (Related #1292)

This commit is contained in:
Francisco Solla 2026-09-24 16:17:30 -03:00
parent 425361d278
commit d0063d39f3
16 changed files with 267 additions and 219 deletions

View file

@ -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.**

View file

@ -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
(`… <mark> …`) 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.

View file

@ -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,
}),
})
}

View file

@ -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,

View file

@ -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<ReturnType<typeof loadWebSpeechAcervoPageData>>
@ -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<ReturnType<typeof loadSpeechAcervoPageData>> }) => {
const hasFilters = speechHasActiveFilters(data.state)
const themeMode = data.state.mode === 'tema'
const themeActive = themeMode && data.themeApplied && !data.themeUnavailable
return (
<CampaignListPendingBoundary>
<SpeechAcervoFilters
key={buildSpeechFiltersKey(data.state)}
state={data.state}
filterOptions={data.filterOptions}
themeUnavailable={data.themeUnavailable}
/>
{data.themeUnavailable ? <SpeechThemeFallbackNotice /> : null}
<CampaignListResults>
{themeMode ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">
{data.themeUnavailable
? 'Resultados por termo exato'
: themeActive
? 'Resultados por tema'
: 'Resultados'}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{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.'}
</p>
</div>
{data.themeUnavailable ? <CampaignThemeRetryButton /> : null}
</div>
) : null}
{data.rows.length > 0 ? (
<SpeechResultList rows={data.rows} query={data.state.q} />
) : (
<CampaignListEmptyState
icon={SearchXIcon}
className={themeActive ? 'border-solid' : undefined}
mediaClassName={themeActive ? 'size-12 rounded-full' : undefined}
contentClassName={themeActive ? 'max-w-none' : undefined}
title={
themeActive
? 'Nenhuma fala encontrada para este tema'
: data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: 'Nenhuma fala encontrada'
}
description={
themeActive
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
: hasFilters
? 'Tente outro termo, remova filtros ou busque por um tema.'
: 'O acervo ainda não tem falas importadas.'
}
>
{themeActive ? (
<div className="flex flex-wrap items-center justify-center gap-2">
<SpeechRefineSearchButton />
<Button asChild variant="outline" className="min-h-11">
<Link href={buildSpeechListHref({ ...data.state, mode: undefined }, 1)}>
Usar termo exato
</Link>
</Button>
<Button asChild variant="ghost" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar filtros</Link>
</Button>
</div>
) : (
<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>
)
}
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 (
<CampaignPageShell aria-label="Acervo de falas na internet">
<AcervoHeader source="internet" />
<WebSpeechSource data={webSpeeches} />
<WebSpeechesSource data={webSpeeches} />
</CampaignPageShell>
)
}
@ -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 (
<CampaignPageShell aria-label="Acervo de falas">
<AcervoHeader source="camara" />
<CampaignListPendingBoundary>
<SpeechAcervoFilters
key={buildSpeechFiltersKey(data.state)}
state={data.state}
filterOptions={data.filterOptions}
themeUnavailable={data.themeUnavailable}
/>
{data.themeUnavailable ? <SpeechThemeFallbackNotice /> : null}
<CampaignListResults>
{themeMode ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">
{data.themeUnavailable
? 'Resultados por termo exato'
: themeActive
? 'Resultados por tema'
: 'Resultados'}
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{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.'}
</p>
</div>
{data.themeUnavailable ? <CampaignThemeRetryButton /> : null}
</div>
) : null}
{data.rows.length > 0 ? (
<SpeechResultList rows={data.rows} query={data.state.q} />
) : (
<CampaignListEmptyState
icon={SearchXIcon}
className={themeActive ? 'border-solid' : undefined}
mediaClassName={themeActive ? 'size-12 rounded-full' : undefined}
contentClassName={themeActive ? 'max-w-none' : undefined}
title={
themeActive
? 'Nenhuma fala encontrada para este tema'
: data.state.q
? `Nenhuma fala encontrada para "${data.state.q}"`
: 'Nenhuma fala encontrada'
}
description={
themeActive
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
: hasFilters
? 'Tente outro termo, remova filtros ou busque por um tema.'
: 'O acervo ainda não tem falas importadas.'
}
>
{themeActive ? (
<div className="flex flex-wrap items-center justify-center gap-2">
<SpeechRefineSearchButton />
<Button asChild variant="outline" className="min-h-11">
<Link href={buildSpeechListHref({ ...data.state, mode: undefined }, 1)}>
Usar termo exato
</Link>
</Button>
<Button asChild variant="ghost" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_ACERVO}>Limpar filtros</Link>
</Button>
</div>
) : (
<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>
<CamaraSource data={data} />
</CampaignPageShell>
)
}

View file

@ -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 (

View file

@ -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[]

View file

@ -16,17 +16,14 @@ const PLATFORM_CLASSES: Record<WebSpeechPlatform, string> = {
export const WebSpeechPlatformPill = ({
platform,
label,
className,
}: {
platform: WebSpeechPlatform | null
label: string
className?: string
}) => (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold',
platform ? PLATFORM_CLASSES[platform] : 'border border-border bg-muted text-muted-foreground',
className,
)}
>
{label}

View file

@ -8,7 +8,7 @@
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
export type RawAcervoSourceParams = Record<string, string | string[] | undefined>
type RawAcervoSourceParams = Record<string, string | string[] | undefined>
export const ACERVO_SOURCE_PARAM = 'source'
export const ACERVO_SOURCE_ENVIADAS = 'enviadas'

View file

@ -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
}

View file

@ -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

View file

@ -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' } : {}),
},
})

View file

@ -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<WebSpeechAcervoPageData> => {
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.

View file

@ -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

View file

@ -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)
})
})

View file

@ -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', () => {