mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
refactor(C216): aplica achados do simplify (Related #1292)
This commit is contained in:
parent
425361d278
commit
d0063d39f3
16 changed files with 267 additions and 219 deletions
|
|
@ -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.**
|
||||
|
|
|
|||
59
docs/plans/escala-dry-pos-c216.md
Normal file
59
docs/plans/escala-dry-pos-c216.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' } : {}),
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue