refactor(C216-FOLLOWUP-DRY): dono único do gate de mídia privada (F1) (Related #1325)

This commit is contained in:
Francisco Solla 2026-09-24 18:23:44 -03:00
parent b119d203aa
commit 02329fa5eb
8 changed files with 272 additions and 140 deletions

View file

@ -0,0 +1,126 @@
# Impl: Escala/DRY pós-C216 — débitos do simplify da terceira fonte do acervo
Status: aprovado
Atualizado em: 2026-09-24
Issue: #1325
Intenção: docs/plans/escala-dry-pos-c216.md
Appetite restante: herdado (~1 dia eng fill-in)
## Leitura da intenção
- **Outcome:** os três débitos do `/simplify` do C216 saem numa trilha ordenada (lock primeiro): (F1) o gate fail-closed replicado nas rotas de mídia privada vira um dono único em `utilities/privateMedia/`, com as rotas mantendo só o HTTP (range/disposition/404); (F2) o heading de resultados dos três ramos da página do acervo vira scaffold parametrizado por copy, sem mudar byte de copy/DOM; (F3) o excerpt truncado e os chips dos dois cards de fala viram componentes do dono do highlight — o gatilho declarado ("um terceiro card de fala") já está vencido com os 2 consumidores.
- **O que NÃO negociar:**
- 404 silencioso e `overrideAccess: false` no gate de mídia — nenhuma resposta HTTP muda (bytes/206/attachment/404 pinados por int/e2e).
- F2 byte-idêntico nos três ramos (`CampaignListEmptyState` com ternários, guard do footer, right-side divergente Câmara × web/gravações); pin = e2e `campaignSpeechAcervo`.
- Sem migration, sem Consent, sem tocar contrato de URL pública, sem superfície visual nova (Impeccable A).
- Identificadores em inglês; copy pt-BR intocada.
- **O que reavaliar (hipóteses do explorer vs. código lido):**
- O helper da intenção (`loadPrivateMediaForActors`) não existe; a intenção deixou o predicado a cargo do helper. A forma final (D1) delibera onde cada predicado mora e confirma que gravações exigem DOIS predicados em runtime: `isRecordingStatus` não é só narrowing — uma string fora do vocabulário no DB passaria por `canServeRecordingMedia` (`!== 'uploading'`) e a rota serviria. O helper não pode esconder essa conjunção.
- `loadWebSpeechMediaForActor` só tem os 2 call sites das rotas web (`grep` confirma). Migrar as duas para o helper o deixa sem consumidor e knip (`exports: error`) quebra — ele SAI junto; os pins e2e das duas rotas continuam valendo porque as respostas são idênticas.
- A duplicação real de F2 é a copy (2 ternários × 3 ramos); list/empty/footer divergem de verdade por ramo. A extração pode ser só o heading, não um shell pass-through (D2).
- O `quoted` do `SpeechThemeProvenance` cobre o 3º site do excerpt sem mudar bytes (D3), mas o caminho tema não é pinado por e2e (DeepSeek blanked no Playwright) — o pin virá de unit novo.
## Abordagem recomendada
```mermaid
flowchart LR
R1["reels [kind]"] --> G
R2["gravacoes [id]"] --> G
R3["conteudos [id] GET"] --> G
R4["internet [id] arquivo"] --> G
R5["internet [id] capa"] --> G
G["loadPrivateMediaForActor<br/>auth → id → findByID(catch null)<br/>→ isServable → artifactOf"] --> M["{ doc, media } | null"]
M --> H["rota mantém o HTTP<br/>notFound 404 silencioso · range · disposition"]
H --> P["buildPrivateMediaResponse<br/>(dono existente)"]
```
**Opções consideradas:** A) helper único com `isServable` + `artifactOf` (dois callbacks nomeados; o ramo sem kill switch declara `isServable: () => true`) | B) helper único com `servableArtifactOf` (predicado embutido no picker) | C) helper só com gate+read+shape e predicados nas rotas | D) factory de rota `createPrivateMediaRoute`.
**Recomendação:** A — porque o gate é fail-closed e o compilador passa a exigir que TODO call site declare sua elegibilidade (`isServable` obrigatório): esquecer o kill switch vira erro de tipo, não um `doc.media` silenciosamente servido. Os predicados de domínio continuam funções nomeadas do dono (`canServeReelMedia`, `isRecordingStatus && canServeRecordingMedia`, `origin === 'web'`) em vez de ternários enterrados no picker.
**Rejeitadas:** B porque um call site novo pode passar um picker puro (`(doc) => doc.media`) sem nenhum sinal de compilação — fail open por omissão, com o contrato só no nome/JSDoc. C porque mantém o kill switch re-escrito nas 5 rotas — exatamente o débito que F1 nomeia. D porque esconde o HTTP (range/disposition/404) que a intenção quer explícito nas rotas e acoplaria 5 rotas a um template de resposta único.
### Decisões de engenharia
**D1 (F1) — O gate vira `loadPrivateMediaForActor`; `isServable` e `artifactOf` são dois callbacks obrigatórios.**
`Decisão: novo src/utilities/privateMedia/privateMediaGate.ts exportando loadPrivateMediaForActor({ payload, collection, id, select?, isServable, artifactOf }) → Promise<{ doc, media } | null>. O helper faz, nesta ordem: getCampaignUser + canReadCommunicationCatalog → null; Number(id) inteiro > 0 → null; findByID({ collection, id, depth: 1, select, user, overrideAccess: false }).catch(() => null) → null; isServable(doc) → null; artifactOf(doc) com typeof === 'object' → null; devolve { doc, media }. Tipagem: TSlug extends CollectionSlug; doc: DataFromCollectionSlug<TSlug>; select?: SelectType; media: PrivateMediaFile (tipo do dono privateMediaResponse.ts); sem cast — verificado por probe de compilação: select: SelectType satisfaz o generic do findByID e o doc chega ao callback como DataFromCollectionSlug<TSlug>. Tipos auxiliares ficam internos (sem export).`
`Por quê: as 5 rotas compartilham auth+id+read+shape+silêncio; cada domínio injeta só o que é dele. isServable obrigatório mata a omissão fail-open no compilador; artifactOf mantém o mapa campo-por-kind (reel) e campo-por-rota (web) no call site; os predicados ficam como funções nomeadas dos donos.`
`Rejeitadas: isServable opcional (omissão silenciosa); predicado embutido no artifactOf (sem sinal de compilação, contrato só no nome — variante B); predicado depois do shape-check (mesmo 404, mas o kill switch deve ser a primeira barreira lógica); manter loadWebSpeechMediaForActor (export sem consumidor ⇒ knip erro, e o gate seguiria re-escrito em 2 rotas).`
Ajustes de rota que acompanham D1 (comportamento idêntico, mesmos 404s):
- **`.../comunicacao/reels/[id]/media/[kind]/route.ts:33-69` (GET):** `const { id, kind } = await params` + `isReelMediaKind(kind)` ficam (linha 42; o guard é o narrowing de `reelMediaFieldByKind[kind]`); helper com `isServable: (reel) => canServeReelMedia(reel.status)` e `artifactOf: (reel) => reel[reelMediaFieldByKind[kind]]`; `select` omitido (como hoje). `staticDir` passa a `resolvePrivateMediaStaticDir(payload, REEL_MEDIA_SLUG)` (as linhas 59-61 hand-rollam o mesmo read já exportado pelo dono).
- **`.../comunicacao/acervo/gravacoes/[id]/arquivo/route.ts:28-68` (GET):** `select: { status: true, media: true }`; `isServable: (doc) => isRecordingStatus(doc.status) && canServeRecordingMedia(doc.status)` (a conjunção é runtime, não narrowing); `artifactOf: (doc) => doc.media`; `staticDir` via `resolvePrivateMediaStaticDir(payload, RECORDING_MEDIA_SLUG)`.
- **`.../comunicacao/conteudos/[id]/arquivo/route.ts:54-83` (GET; POST 85-178 intocado):** `select: { media: true }`; `isServable: () => true` (o domínio não tem kill switch de mídia — declaração explícita); `artifactOf: (doc) => doc.media`. `pieceIdFrom` (48-52) fica (o POST usa).
- **`.../comunicacao/acervo/internet/[id]/arquivo/route.ts:32-57` (GET):** `select: { origin: true, title: true, mirroredMedia: true }`; `isServable: (doc) => doc.origin === 'web'`; `artifactOf: (doc) => doc.mirroredMedia`; disposition segue com `webSpeechDownloadFilename({ title: found.doc.title, storedFilename: found.media.filename })`.
- **`.../comunicacao/acervo/internet/[id]/capa/route.ts:30-51` (GET):** `select: { origin: true, thumbnail: true }`; `isServable: (doc) => doc.origin === 'web'`; `artifactOf: (doc) => doc.thumbnail`; `download: false` fica.
- As 5 rotas perdem os imports de `getCampaignUser`/`canReadCommunicationCatalog` e **mantêm** `notFound` (404 silencioso com `PRIVATE_MEDIA_CACHE_CONTROL`), `buildPrivateMediaResponse` e o HTTP; os docblocks passam a apontar o gate como `loadPrivateMediaForActor`.
- **`src/utilities/speech/speechPageData.ts:440-471`:** removem-se `WebSpeechMediaField` e `loadWebSpeechMediaForActor` (sem consumidor após a migração) e o import de `InternetSpeechMedia` (linha 7, só usado ali); `canReadCommunicationCatalog` (linha 5) fica (uso na linha 152). `import 'server-only'` no arquivo novo (gate acoplado à auth).
**D2 (F2) — O scaffold é só o heading; list/empty/footer ficam no ramo.**
`Decisão: componente LOCAL (não exportado) AcervoResultsHeading em acervo/page.tsx (junto do AcervoHeader, 60-90) com props { themeMode, themeActive, themeUnavailable, idleTitle, subject, controls }. Ele possui o flex row + bloco esquerdo + os DOIS ternários de copy: título (themeUnavailable → 'Resultados por termo exato'; themeActive → 'Resultados por tema'; senão idleTitle) e parágrafo (só quando themeMode: 'Comportamento atual do acervo.' | 'Confira o indício em cada ${subject} antes de abrir.' | 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'), com subject: 'fala' | 'gravação'. controls recebe o right-side já montado: Câmara passa o retry solto (ou null), web/gravações passam o wrapper flex flex-wrap items-end gap-2 com retry + AcervoSortSelect (hint próprio). Câmara chama sob {themeMode ? … : null} (idleTitle="Resultados"); web/gravações sempre (idleTitle='Resultados encontrados'/'Gravações encontradas').`
`Por quê: a duplicação real é a copy (2 ternários × 3 ramos com o substantivo divergindo); o resto difere de verdade (filtros, notices, refresher, list/empty com icons/classNames/CTAs/links próprios, footer com props próprias). ReactNode no right-side reproduz os DOIS layouts sem flag de layout e sem ternário de copy nos ramos; bytes idênticos (JSX não emite whitespace entre elementos em linhas separadas).`
`Rejeitadas: shell completo (heading+list+empty+footer, ~8 props) — pass-through raso que esconde a estrutura específica de cada ramo e não remove a duplicação de copy; componente em shared/ exportado — consumidores só neste arquivo, sem 3º consumidor fora dele (o gatilho de extração), e knip exige consumidor para export; unificar o wrapper do right-side — mudaria bytes da Câmara (retry ganharia wrapper/gap); unificar os empty states — copy/CTAs/classes são genuinamente por ramo.`
**D3 (F3) — `SpeechExcerpt` com `className` por site e `quoted`; chips por grupos.**
`Decisão: SpeechExcerpt({ excerpt, className, quoted? }) em novo src/components/campaign/speech/SpeechExcerpt.tsx: null sem parts; '… '/' …' conforme truncatedStart/End; com quoted envolve em “ … ”. className fica por call site (Câmara 'text-sm leading-relaxed text-foreground/90'; web 'mt-2 text-sm leading-6 text-foreground/90'; provenance 'mt-1 text-sm leading-6 text-foreground/90'). SpeechResultChips({ groups: { key, items, max, variant?, className? }[] }) em novo SpeechResultChips.tsx: renderiza até max por grupo, soma o +N de todos os grupos e usa Badge (secondary com className 'font-normal'; +N outline com 'font-normal text-muted-foreground'); container e guard de vazio continuam no call site. Câmara passa 3 grupos (topics/3, scopes/2, keywords/3 outline muted — keywords mapeadas de string para { value, label }); web passa 2.`
`Por quê: são 3 sites do MESMO truncamento (os dois cards + o provenance C192, no mesmo arquivo do card) e 2 sites de chips com caps 3/2 (+ 3 keywords na Câmara); o +N somando grupos é a única forma de manter a semântica da Câmara sem duplicar a soma. className explícito evita mudança de pixel (Impeccable A) e é honesto — as classes divergem de propósito.`
`Rejeitadas: unificar className/line-height (muda pixel sem design review; o e2e não pina line-height, mas a intenção é A); incluir RecordingResultCard.tsx:57-62 (terceiro excerpt é de OUTRO card — fora do gatilho "3º card de fala"); API de chips por props fixas (não permitiria 3 grupos × 2); exportar os tipos dos grupos (knip types: error sem consumidor externo — os call sites usam literais contextuais).`
### Componentes / mudanças
- **`loadPrivateMediaForActor`** (novo `src/utilities/privateMedia/privateMediaGate.ts`): gate fail-closed + read + shape; `import 'server-only'`; sem barrel.
- **5 rotas GET** de mídia privada: passam ao helper e mantêm o HTTP (detalhado em D1).
- **`speechPageData.ts`**: remoção de `WebSpeechMediaField` + `loadWebSpeechMediaForActor` (440-471) e do import órfão (linha 7).
- **`AcervoResultsHeading`** (local em `src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx`): substitui os blocos 116-143 (RecordingsSource), 238-265 (WebSpeechesSource) e 344-365 (CamaraSource).
- **`SpeechExcerpt`** (novo `src/components/campaign/speech/SpeechExcerpt.tsx`): usado em `SpeechResultCard.tsx` (excerpt local 23-33; `SpeechThemeProvenance` 40-55 com `quoted`) e `WebSpeechResultCard.tsx:68-74`.
- **`SpeechResultChips`** (novo `src/components/campaign/speech/SpeechResultChips.tsx`): usado em `SpeechResultCard.tsx:213-257` (3 grupos) e `WebSpeechResultCard.tsx:11-13,24-28,76-94` (2 grupos).
- **Migration:** sem migration (nenhum schema/collection/global tocado).
- **Access / Consent:** inalterados — o helper reusa `getCampaignUser` + `canReadCommunicationCatalog` fail-closed; nenhum Consent novo, nenhum ID hardcoded, nenhum PII novo.
- **UI:** Impeccable A — sem superfície visual nova; F2/F3 são refactor byte-idêntico (classes preservadas por call site).
- **Testes:** novo `tests/unit/speechResultCards.unit.spec.ts` com `renderToStaticMarkup` (padrão de `campaignComponents.unit.spec.ts`): pin do excerpt (ellipses, `mark`, `quoted`, vazio) e do `+N` somando grupos (Câmara 3+2+3 com caps → +3; web 2 grupos → +1).
## Fases verificáveis
1. **F1 (tracer, expensive_lock) — helper + 5 rotas.** Implementa `privateMediaGate.ts`, migra os 5 GETs, remove `loadWebSpeechMediaForActor`/`WebSpeechMediaField`. Verificação: `pnpm typecheck`, `pnpm knip`, `pnpm check:cycles`; int `tests/int/reel.int.spec.ts`, `recording.int.spec.ts`, `contentPiece.int.spec.ts`, `webSpeechAcervo.int.spec.ts`; e2e `campaignReel` (kill switch/anonymous/attachment) e os testes de mídia do `campaignSpeechAcervo` (gravações 686-721, internet arquivo+capa 1288-1358, conteúdos 1609-1649). Nenhum byte de resposta muda.
2. **F2 (scaffold) — heading dos três ramos.** Cria `AcervoResultsHeading` e substitui os 3 blocos. Verificação: e2e `campaignSpeechAcervo` inteiro (ternários de tema/fallback, vazios, footer, sort) — bytes idênticos.
3. **F3 (excerpt/chips) — unit + cards.** Cria os 2 componentes, migra os 3 sites de excerpt e os 2 de chips, adiciona o unit novo. Verificação: `pnpm test:unit` + e2e `campaignSpeechAcervo` (listas Câmara/web/gravações).
4. **Gates — `pnpm gate:fast`; `pnpm push`** (o CI do PR roda a cascata completa, incl. os e2e da vertical). Commits pequenos por fase, F1 primeiro.
## Rabbit holes / Não escopo (engenharia)
- Rota `poster` (`acervo/[id]/poster/route.ts`) e demais rotas de mídia fora das 5 — o gate delas não está neste lote.
- POST de upload/anexo (`conteudos/[id]/arquivo` POST, `conteudos/enviar`, `gravacoes/enviar`) — auth própria por envelope de erro (401/403), não é o 404 silencioso do gate de leitura.
- Central de Conteúdos público (`(frontend)/conteudos/[slug]/midia`) — contrato público, outro gate.
- `RecordingResultCard.tsx:57-62` (terceiro excerpt, de outro card) — o gatilho do F3 é um 3º card de FALA.
- `speechCoverage`/Sollinha/C217.
- Unificar `speechHasActiveFilters` × `recordingHasActiveFilters` (shapes `phases` × `people`) — defer com gatilho no impl do C216.
- Centralizar fixtures de bytes MP4/MP3 dos int specs (2 sites) — defer.
- Unificar line-height/aspas do excerpt ou o wrapper do right-side — mudaria pixel.
- Não criar `src/lib/privateMediaGate` (o dono é `utilities/privateMedia/`) nem `index.ts`/barrel.
## Riscos e mitigação
- **F1 — 404 silencioso/`overrideAccess:false`:** o helper é o único que lê; `isServable` obrigatório no tipo; ints cobrem advisor/anônimo → 404 e conteúdo → 200; qualquer desvio de bytes é regressão pinada.
- **F1 — guard de status das gravações:** manter `isRecordingStatus && canServeRecordingMedia` (não só narrowing): string desconhecida no DB hoje 404; sem o guard, `!== 'uploading'` serviria.
- **F1 — knip/cycles:** remover o export órfão no mesmo commit (`types` error pega `WebSpeechMediaField`; `exports` pega `loadWebSpeechMediaForActor`); `check:cycles` garante 0 ciclos (gate→response só em tipo, uma direção).
- **F1 — mocks dos int specs:** `vi.mock('@/utilities/campaignAuth', () => ({ getCampaignUser }))` — o helper importa só `getCampaignUser` desse módulo, então o mock segue completo.
- **F2 — bytes de copy/DOM:** o componente reproduz exatamente as duas estruturas de right-side via `controls` e preserva a ordem dos ternários (título: unavailable → active → idle; parágrafo só em `themeMode`); o e2e inteiro é o pin.
- **F3 — bytes do excerpt:** concatenação `… `/` …` e aspas `“ ”` reproduzidas char a char; como o caminho tema não é e2e-pinado (DeepSeek blanked), o unit novo é o pin desse site.
- **F3 — chips:** caps por grupo preservam a soma (Câmara soma keywords; web não); keys novas não renderizam bytes.
- **Impeccable A:** nenhuma classe nova/unificada; mudança visual não pretendida quebra o e2e da vertical (C154/C199/C211/C216).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: 5 rotas com o MESMO contrato HTTP (bytes/206/attachment/404), página dos 3 ramos byte-idêntica, cards de fala byte-idênticos.
- [ ] Invariantes AGENTS/engineering-standards: sem migration, sem Consent, sem URL pública; gate fail-closed preservado; arquivo novo em subpasta de `src/utilities` com `server-only`; sem barrel; identificadores em inglês/copy pt-BR.
- [ ] Testes: unit novo verde; int reel/recording/contentPiece/webSpeechAcervo verdes; e2e `campaignSpeechAcervo` + `campaignReel` verdes; `pnpm knip`, `pnpm check:cycles`, `pnpm gate:fast` verdes.
- [ ] Entrega: commits pequenos por fase (F1 lock primeiro) num PR único; entrada curta em `docs/changelog/<data>-<id>.md`.
## Self-score de decision-quality
Nota: 5/5.
1. **Decisões caras têm rejeitadas?** Sim: D1 (lock de gate fail-closed) tem variantes A/B/C/D com 4 rejeitadas nomeadas; D2 rejeita shell pass-through, `shared/` e unificação do wrapper; D3 rejeita unificação de line-height e a expansão ao `RecordingResultCard`. As baratas (nomes, ordem) ficaram fora.
2. **Cabe no appetite (~1 dia fill-in)?** Sim: 3 arquivos novos pequenos + 7 editados + 1 unit, sem migration/schema; o lock (F1) usa os specs existentes como prova, e F2/F3 são mecânicos com pin pronto.
3. **Rabbit holes nomeados?** Sim: secção dedicada (poster, POSTs, Central pública, RecordingResultCard, speechCoverage/C217, filtros gêmeos, fixtures, line-height, barrel).
4. **Depth check:** Sim — reusa `buildPrivateMediaResponse`/`resolvePrivateMediaStaticDir` e o dono `utilities/privateMedia/`; reusa `Badge`/`SpeechHighlightParts`; não cria adapter/factory raso; o helper tem profundidade real (5 call sites, invariante fail-closed verificado no compilador).
5. **Intenção permanece satisfeita?** Sim — F1/F2/F3 são exatamente os 3 débitos, na ordem "lock primeiro"; nenhuma resposta, copy ou URL muda; o outcome de produto não foi reescrito pela engenharia.

View file

@ -2,11 +2,13 @@ import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import { canServeRecordingMedia, isRecordingStatus, RECORDING_MEDIA_SLUG } from '@/lib/recording'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { buildPrivateMediaResponse } from '@/utilities/privateMedia/privateMediaResponse'
import { loadPrivateMediaForActor } from '@/utilities/privateMedia/privateMediaGate'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
@ -29,39 +31,22 @@ export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const { id } = await params
const recordingId = Number(id)
if (!Number.isInteger(recordingId) || recordingId <= 0) return notFound()
const payload = await getPayload({ config })
const recording = await payload
.findByID({
collection: 'recording',
id: recordingId,
depth: 1,
select: { status: true, media: true },
user,
overrideAccess: false,
})
.catch(() => null)
if (!recording || !isRecordingStatus(recording.status)) return notFound()
if (!canServeRecordingMedia(recording.status)) return notFound()
const media = recording.media
if (!media || typeof media !== 'object') return notFound()
const upload = payload.collections[RECORDING_MEDIA_SLUG].config.upload
const staticDir =
upload && typeof upload === 'object' && upload.staticDir
? upload.staticDir
: RECORDING_MEDIA_SLUG
const found = await loadPrivateMediaForActor({
payload,
collection: 'recording',
id: Number(id),
select: { status: true, media: true },
isServable: (recording) =>
isRecordingStatus(recording.status) && canServeRecordingMedia(recording.status),
artifactOf: (recording) => recording.media,
})
if (!found) return notFound()
return buildPrivateMediaResponse({
media,
staticDir,
media: found.media,
staticDir: resolvePrivateMediaStaticDir(payload, RECORDING_MEDIA_SLUG),
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
})

View file

@ -2,23 +2,22 @@ import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import { INTERNET_SPEECH_MEDIA_SLUG, webSpeechDownloadFilename } from '@/lib/webSpeech'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadPrivateMediaForActor } from '@/utilities/privateMedia/privateMediaGate'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
import { loadWebSpeechMediaForActor } from '@/utilities/speech/speechPageData'
export const dynamic = 'force-dynamic'
/**
* C216 — the only door to a web speech's mirrored file. Lives under `/campanha`
* because the `campaign-token` cookie is scoped to that path (a `<video>` sends
* the cookie, never an Authorization header). The gate is the same
* communication catalog predicate as the collection access; only web rows are
* the cookie, never an Authorization header). The gate is the shared
* `loadPrivateMediaForActor` (same communication catalog predicate as the
* collection access); only web rows are
* served and every denial is a silent `404` so the route never leaks which
* speeches exist. The download name is derived from the speech title (C215 S4).
*/
@ -33,15 +32,16 @@ export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const { id } = await params
const speechId = Number(id)
if (!Number.isInteger(speechId) || speechId <= 0) return notFound()
const payload = await getPayload({ config })
const found = await loadWebSpeechMediaForActor(payload, user, speechId, 'mirroredMedia')
const found = await loadPrivateMediaForActor({
payload,
collection: 'speech',
id: Number(id),
select: { origin: true, title: true, mirroredMedia: true },
isServable: (speech) => speech.origin === 'web',
artifactOf: (speech) => speech.mirroredMedia,
})
if (!found) return notFound()
return buildPrivateMediaResponse({
@ -50,7 +50,7 @@ export const GET = async (
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
dispositionFilename: webSpeechDownloadFilename({
title: found.title,
title: found.doc.title,
storedFilename: found.media.filename,
}),
})

View file

@ -2,22 +2,21 @@ import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadPrivateMediaForActor } from '@/utilities/privateMedia/privateMediaGate'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
import { loadWebSpeechMediaForActor } from '@/utilities/speech/speechPageData'
export const dynamic = 'force-dynamic'
/**
* C216 — the authenticated cover of one web speech. The thumbnail lives in the
* private `internetSpeechMedia` collection, so it can never go through the
* public `/api/media/file` proxy; a row without a captured thumbnail answers
* public `/api/media/file` proxy; the gate is the shared
* `loadPrivateMediaForActor` and a row without a captured thumbnail answers
* `404` and the list renders its neutral placeholder.
*/
@ -31,15 +30,16 @@ export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const { id } = await params
const speechId = Number(id)
if (!Number.isInteger(speechId) || speechId <= 0) return notFound()
const payload = await getPayload({ config })
const found = await loadWebSpeechMediaForActor(payload, user, speechId, 'thumbnail')
const found = await loadPrivateMediaForActor({
payload,
collection: 'speech',
id: Number(id),
select: { origin: true, thumbnail: true },
isServable: (speech) => speech.origin === 'web',
artifactOf: (speech) => speech.thumbnail,
})
if (!found) return notFound()
return buildPrivateMediaResponse({

View file

@ -17,6 +17,7 @@ import { getCampaignUser } from '@/utilities/campaignAuth'
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import { attachContentPieceMedia } from '@/utilities/content/contentPieceUpload'
import { loadPrivateMediaForActor } from '@/utilities/privateMedia/privateMediaGate'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
@ -30,9 +31,10 @@ export const dynamic = 'force-dynamic'
/**
* C211 — the only door to a piece file. Lives under `/campanha` because the
* `campaign-token` cookie is scoped to that path (a `<video>`/`<img>` sends the
* cookie, never an Authorization header). The gate is the same communication
* catalog predicate as the collection access, and every denial is a silent
* `404` so the route never leaks which pieces exist.
* cookie, never an Authorization header). The gate is the shared
* `loadPrivateMediaForActor` (same communication catalog predicate as the
* collection access, no kill switch over the piece status), and every denial is
* a silent `404` so the route never leaks which pieces exist.
*
* `GET` serves the archived file (range/download). `POST` attaches the original
* file to a link piece as a RAW body — the "Anexar arquivo original" action,
@ -55,27 +57,20 @@ export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const pieceId = await pieceIdFrom(params)
if (pieceId === null) return notFound()
const { id } = await params
const payload = await getPayload({ config })
const piece = await payload
.findByID({
collection: 'contentPiece',
id: pieceId,
depth: 1,
select: { media: true },
user,
overrideAccess: false,
})
.catch(() => null)
if (!piece?.media || typeof piece.media !== 'object') return notFound()
const found = await loadPrivateMediaForActor({
payload,
collection: 'contentPiece',
id: Number(id),
select: { media: true },
isServable: () => true,
artifactOf: (piece) => piece.media,
})
if (!found) return notFound()
return buildPrivateMediaResponse({
media: piece.media,
media: found.media,
staticDir: resolvePrivateMediaStaticDir(payload, CONTENT_MEDIA_SLUG),
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',

View file

@ -2,7 +2,6 @@ import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import {
canServeReelMedia,
@ -10,16 +9,20 @@ import {
REEL_MEDIA_SLUG,
reelMediaFieldByKind,
} from '@/lib/reel'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { buildPrivateMediaResponse } from '@/utilities/privateMedia/privateMediaResponse'
import { loadPrivateMediaForActor } from '@/utilities/privateMedia/privateMediaGate'
import {
buildPrivateMediaResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
/**
* C193 — the only door to a reel file. Lives under `/campanha` because the
* `campaign-token` cookie is scoped to that path (a `<video>`/`<img>` sends the
* cookie, never an Authorization header). The gate is the same communication
* catalog predicate as the collection access; only a `published` reel serves
* cookie, never an Authorization header). The gate is the shared
* `loadPrivateMediaForActor` (same communication catalog predicate as the
* collection access); only a `published` reel serves
* its files (the kill switch), and every denial is a silent `404` so the route
* never leaks which reels exist.
*/
@ -34,35 +37,22 @@ export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string; kind: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const { id, kind } = await params
const reelId = Number(id)
if (!Number.isInteger(reelId) || reelId <= 0 || !isReelMediaKind(kind)) return notFound()
if (!isReelMediaKind(kind)) return notFound()
const payload = await getPayload({ config })
const reel = await payload
.findByID({
collection: 'reel',
id: reelId,
depth: 1,
user,
overrideAccess: false,
})
.catch(() => null)
if (!reel || !canServeReelMedia(reel.status)) return notFound()
const media = reel[reelMediaFieldByKind[kind]]
if (!media || typeof media !== 'object') return notFound()
const upload = payload.collections[REEL_MEDIA_SLUG].config.upload
const staticDir =
upload && typeof upload === 'object' && upload.staticDir ? upload.staticDir : REEL_MEDIA_SLUG
const found = await loadPrivateMediaForActor({
payload,
collection: 'reel',
id: Number(id),
isServable: (reel) => canServeReelMedia(reel.status),
artifactOf: (reel) => reel[reelMediaFieldByKind[kind]],
})
if (!found) return notFound()
return buildPrivateMediaResponse({
media,
staticDir,
media: found.media,
staticDir: resolvePrivateMediaStaticDir(payload, REEL_MEDIA_SLUG),
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
})

View file

@ -0,0 +1,69 @@
import 'server-only'
import type { DataFromCollectionSlug, Payload, SelectType } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { getCampaignUser } from '@/utilities/campaignAuth'
import type { PrivateMediaFile } from '@/utilities/privateMedia/privateMediaResponse'
/**
* The private upload collections the gate serves. Deliberately a closed union
* (not `CollectionSlug`): keeping the domain narrow is what makes the generic
* `findByID` readable to the compiler and documents which doors exist.
*/
type PrivateMediaCollectionSlug = 'contentPiece' | 'recording' | 'reel' | 'speech'
/**
* C216-FOLLOWUP-DRY — the single owner of the fail-closed gate every private
* media GET route repeats: authenticates the campaign actor, refuses a
* malformed id, reads the row with `overrideAccess: false` and answers `null`
* for EVERY denial (the route maps it to its silent 404, so the response never
* leaks which artifacts exist). The caller injects only what is its own: the
* collection, the optional `select`, the eligibility predicate (`isServable`,
* mandatory so a new route cannot silently skip its kill switch) and the media
* field picker (`artifactOf`).
*
* The HTTP contract (range, disposition, 404) stays in the route — this helper
* never builds a response.
*/
export const loadPrivateMediaForActor = async <
TSlug extends PrivateMediaCollectionSlug,
TMedia extends PrivateMediaFile,
>({
payload,
collection,
id,
select,
isServable,
artifactOf,
}: {
payload: Payload
collection: TSlug
id: number
/** Keep the row light: the routes only need the status/origin + media fields. */
select?: SelectType
isServable: (doc: DataFromCollectionSlug<TSlug>) => boolean
artifactOf: (doc: DataFromCollectionSlug<TSlug>) => TMedia | number | null | undefined
}): Promise<{ doc: DataFromCollectionSlug<TSlug>; media: TMedia } | null> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return null
if (!Number.isInteger(id) || id <= 0) return null
const doc = await payload
.findByID({
collection,
id,
depth: 1,
...(select ? { select } : {}),
user,
overrideAccess: false,
})
.catch(() => null)
if (!doc || !isServable(doc)) return null
const artifact = artifactOf(doc)
if (!artifact || typeof artifact !== 'object') return null
return { doc, media: artifact }
}

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, InternetSpeechMedia, Speech } from '@/payload-types'
import type { CampaignUser, Speech } from '@/payload-types'
import {
expandSpeechSearchTheme,
type SpeechThemeExpansionResolver,
@ -437,39 +437,6 @@ export const loadWebSpeechDetailPageData = async (
})
}
/** 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.