From 329662668ec27fef279d0be3415816488cb2776a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 18:23:44 -0300 Subject: [PATCH] =?UTF-8?q?refactor(C216-FOLLOWUP-DRY):=20dono=20=C3=BAnic?= =?UTF-8?q?o=20do=20gate=20de=20m=C3=ADdia=20privada=20(F1)=20(Related=20#?= =?UTF-8?q?1325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/escala-dry-pos-c216-impl.md | 126 ++++++++++++++++++ .../acervo/gravacoes/[id]/arquivo/route.ts | 49 +++---- .../acervo/internet/[id]/arquivo/route.ts | 26 ++-- .../acervo/internet/[id]/capa/route.ts | 22 +-- .../conteudos/[id]/arquivo/route.ts | 37 +++-- .../reels/[id]/media/[kind]/route.ts | 48 +++---- .../privateMedia/privateMediaGate.ts | 69 ++++++++++ src/utilities/speech/speechPageData.ts | 35 +---- 8 files changed, 272 insertions(+), 140 deletions(-) create mode 100644 docs/plans/escala-dry-pos-c216-impl.md create mode 100644 src/utilities/privateMedia/privateMediaGate.ts diff --git a/docs/plans/escala-dry-pos-c216-impl.md b/docs/plans/escala-dry-pos-c216-impl.md new file mode 100644 index 00000000..1bbbc041 --- /dev/null +++ b/docs/plans/escala-dry-pos-c216-impl.md @@ -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
auth → id → findByID(catch null)
→ isServable → artifactOf"] --> M["{ doc, media } | null"] + M --> H["rota mantém o HTTP
notFound 404 silencioso · range · disposition"] + H --> P["buildPrivateMediaResponse
(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; 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. 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/-.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. diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts index 2d94180d..2dc56401 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/gravacoes/[id]/arquivo/route.ts @@ -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 => { - 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', }) diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts index 48b50ced..f2782d7f 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/internet/[id]/arquivo/route.ts @@ -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 `