22 KiB
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
/simplifydo C216 saem numa trilha ordenada (lock primeiro): (F1) o gate fail-closed replicado nas rotas de mídia privada vira um dono único emutilities/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: falseno gate de mídia — nenhuma resposta HTTP muda (bytes/206/attachment/404 pinados por int/e2e). - F2 byte-idêntico nos três ramos (
CampaignListEmptyStatecom ternários, guard do footer, right-side divergente Câmara × web/gravações); pin = e2ecampaignSpeechAcervo. - 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.
- 404 silencioso e
- 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:isRecordingStatusnão é só narrowing — uma string fora do vocabulário no DB passaria porcanServeRecordingMedia(!== 'uploading') e a rota serviria. O helper não pode esconder essa conjunção. loadWebSpeechMediaForActorsó tem os 2 call sites das rotas web (grepconfirma). 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
quoteddoSpeechThemeProvenancecobre 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.
- O helper da intenção (
Abordagem recomendada
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 dereelMediaFieldByKind[kind]); helper comisServable: (reel) => canServeReelMedia(reel.status)eartifactOf: (reel) => reel[reelMediaFieldByKind[kind]];selectomitido (como hoje).staticDirpassa aresolvePrivateMediaStaticDir(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;staticDirviaresolvePrivateMediaStaticDir(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 comwebSpeechDownloadFilename({ 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: falsefica.- As 5 rotas perdem os imports de
getCampaignUser/canReadCommunicationCataloge mantêmnotFound(404 silencioso comPRIVATE_MEDIA_CACHE_CONTROL),buildPrivateMediaResponsee o HTTP; os docblocks passam a apontar o gate comoloadPrivateMediaForActor. src/utilities/speech/speechPageData.ts:440-471: removem-seWebSpeechMediaFieldeloadWebSpeechMediaForActor(sem consumidor após a migração) e o import deInternetSpeechMedia(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 — na execução o tipo SpeechChipGroupsaiu e os 2 call sites anotamSpeechChipGroup[] (knip verde); a hipótese do literal contextual não se confirmou).
Componentes / mudanças
loadPrivateMediaForActor(novosrc/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 deWebSpeechMediaField+loadWebSpeechMediaForActor(440-471) e do import órfão (linha 7).AcervoResultsHeading(local emsrc/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx): substitui os blocos 116-143 (RecordingsSource), 238-265 (WebSpeechesSource) e 344-365 (CamaraSource).SpeechExcerpt(novosrc/components/campaign/speech/SpeechExcerpt.tsx): usado emSpeechResultCard.tsx(excerpt local 23-33;SpeechThemeProvenance40-55 comquoted) eWebSpeechResultCard.tsx:68-74.SpeechResultChips(novosrc/components/campaign/speech/SpeechResultChips.tsx): usado emSpeechResultCard.tsx:213-257(3 grupos) eWebSpeechResultCard.tsx:11-13,24-28,76-94(2 grupos).- Migration: sem migration (nenhum schema/collection/global tocado).
- Access / Consent: inalterados — o helper reusa
getCampaignUser+canReadCommunicationCatalogfail-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.tscomrenderToStaticMarkup(padrão decampaignComponents.unit.spec.ts): pin do excerpt (ellipses,mark,quoted, vazio) e do+Nsomando grupos (Câmara 4+3+4 com caps → +3; web 4+3 → +2).
Fases verificáveis
- F1 (tracer, expensive_lock) — helper + 5 rotas. Implementa
privateMediaGate.ts, migra os 5 GETs, removeloadWebSpeechMediaForActor/WebSpeechMediaField. Verificação:pnpm typecheck,pnpm knip,pnpm check:cycles; inttests/int/reel.int.spec.ts,recording.int.spec.ts,contentPiece.int.spec.ts,webSpeechAcervo.int.spec.ts; e2ecampaignReel(kill switch/anonymous/attachment) e os testes de mídia docampaignSpeechAcervo(gravações 686-721, internet arquivo+capa 1288-1358, conteúdos 1609-1649). Nenhum byte de resposta muda. - F2 (scaffold) — heading dos três ramos. Cria
AcervoResultsHeadinge substitui os 3 blocos. Verificação: e2ecampaignSpeechAcervointeiro (ternários de tema/fallback, vazios, footer, sort) — bytes idênticos. - 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+ e2ecampaignSpeechAcervo(listas Câmara/web/gravações). - 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.
Adiado com gatilho (triage pós-revisão, 2026-09-24)
Nada abaixo atingiu o piso de registro (expensive_lock ≥4). Aplicados na sessão (não reabrir): S1 (JSDoc do isServable), S4 (números dos chips no impl plan), Q1 (subject→itemNoun), Q4 (AcervoResultsControls), Q5 (keywords normalizadas no call site), Q8 (changelog), Q10 (unit com o caso sem +N).
- F1-residual —
selectnão chaveado à collection (privateMediaGate.ts:45). Chave errada/omitida compila e um predicado pode ler campo não selecionado; o efeito é 404 silencioso (fail-closed). O mapped type porDataFromCollectionSlugnão é viável sem cast (probe 2026-09-24):SelectFromCollectionSlugnão é exportado porpayload;Partial<Record<keyof DataFromCollectionSlug<TSlug>, true>>não assigna aoselectdofindByID(undefinedvsSelectIncludeType | true); intersectar comSelectTypecompila, mas o index signature anula o excess-property check. Caminho barato quando doer:satisfies Partial<Record<keyof DataFromCollectionSlug<'<slug>'>, true>>no literal de cada call site (pega chave errada; omissão continua passando). Gatilho: a 6ª rota usar o gate (poster/C217) ou o primeiro 404 rastreado aselectdivergente do predicado. - F1-residual —
notFoundsilencioso em 6 cópias +Number(id)em 5 rotas. ExtrairprivateMediaNotFound()no dono (privateMediaResponse.ts, onde a 6ª cópia já mora — oNextResponsedas rotas é assignável aResponse) e o gate aceitarid: string | number, validando no dono;pieceIdFromdo POST fica (a schema precisa do número). A rota mantém o controle (if (!found) return privateMediaNotFound()). Gatilho: o mesmo acima, ou qualquer mudança no shape do 404 (status/header). - Harness de router duplicado nos unit specs (
campaignComponents.unit.spec.ts:99-120espeechResultCards.unit.spec.ts:17-27). ConsolidarmockAppRouter/renderWithAppRouteremtests/helpers/. O gatilho deescala-dry-pos-b33.md:106(4º spec de router) já vencera antes deste F3 (17 specs mockamnext/navigation); a conversão dovi.mockdecampaignListFilterNavigation.unit.spec.tsentra no mesmo pagamento. Gatilho: o próximo lote que tocar unit specs de componente.
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]/arquivoPOST,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(excerpt de outro card — o gatilho do F3 era um 3º card de FALA, não este). Gatilho: a próxima edição do card (C217/design) troca o bloco peloSpeechExcerptcom className próprio (mt-2 text-sm leading-5 text-foreground/90, bytes idênticos); conferir o casopartsvazio (hoje o site emite as elipses; o componente devolve null).speechCoverage/Sollinha/C217.- Unificar
speechHasActiveFilters×recordingHasActiveFilters(shapesphases×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/) nemindex.ts/barrel.
Riscos e mitigação
- F1 — 404 silencioso/
overrideAccess:false: o helper é o único que lê;isServableobrigató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 (
typeserror pegaWebSpeechMediaField;exportspegaloadWebSpeechMediaForActor);check:cyclesgarante 0 ciclos (gate→response só em tipo, uma direção). - F1 — mocks dos int specs:
vi.mock('@/utilities/campaignAuth', () => ({ getCampaignUser }))— o helper importa sógetCampaignUserdesse módulo, então o mock segue completo. - F2 — bytes de copy/DOM: o componente reproduz exatamente as duas estruturas de right-side via
controlse preserva a ordem dos ternários (título: unavailable → active → idle; parágrafo só emthemeMode); 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/utilitiescomserver-only; sem barrel; identificadores em inglês/copy pt-BR. - Testes: unit novo verde; int reel/recording/contentPiece/webSpeechAcervo verdes; e2e
campaignSpeechAcervo+campaignReelverdes;pnpm knip,pnpm check:cycles,pnpm gate:fastverdes. - 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.
- 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 aoRecordingResultCard. As baratas (nomes, ordem) ficaram fora. - 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.
- Rabbit holes nomeados? Sim: secção dedicada (poster, POSTs, Central pública, RecordingResultCard, speechCoverage/C217, filtros gêmeos, fixtures, line-height, barrel).
- Depth check: Sim — reusa
buildPrivateMediaResponse/resolvePrivateMediaStaticDire o donoutilities/privateMedia/; reusaBadge/SpeechHighlightParts; não cria adapter/factory raso; o helper tem profundidade real (5 call sites, invariante fail-closed verificado no compilador). - 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.