Lista publicados-only com capas 9:16, detalhe com player 9:16 e painel de downloads (vídeo sem áudio primário; com áudio, mp3 e .srt quando existem; transcrição em texto) e kill switch reversível: despublicar tira da lista e bloqueia a mídia (C193 decision B), republicar reativa e re-carimba publishedAt. Fiação: gate communicationCatalog (rename do speechCatalog), 3º sub-item da vertical no nav, rule da lista no chrome, paths e loader próprios; sem migration, sem Consent, sem tocar Media.read ou a rota de mídia do C193. Design tier: DEGRADED — crítica final do designer indisponível no tier primário (limite de uso); requer sign-off humano antes de push/PR.
16 KiB
Impl: Biblioteca de reels na vertical Comunicação
Status: aprovado Atualizado em: 2026-09-18 Issue: #1153 Intenção: docs/plans/reels-biblioteca.md Appetite restante: herdado (~1–2 dias eng; lista + detalhe + kill switch verificáveis com fixtures)
Leitura da intenção
- Outcome:
/campanha/comunicacao/reelslista os reels publicados (capa 9:16 + título), o detalhe abre o reel em 9:16 e oferece os arquivos disponíveis (vídeo sem áudio primário, vídeo com áudio, mp3,.srt, transcrição) para a assessoria trabalhar fora do site; despublicar tira da lista sem apagar e republicar reativa; acesso só acommunicator/coordinator/candidate, fail-closed. - O que NÃO negociar: arquivos privados (rota C193 é a única porta;
Media.readintocado); nada de publicação/edição de vídeo na UI; nenhum reel some por acidente (kill switch reversível); sem busca/filtros no MVP (-publishedAt); semConsentnovo; sem migração (C193 fechou o modelo). - O que reavaliar: a recomendação A da intenção para o link direto do despublicado conflita com o C193 (decisão B, mergeada) — vale B:
draft/unpublishednão servem mídia; o detalhe interno continua abrindo, mas sem player/downloads. Verifiquei que o regex/acervo/[^/]+do chrome (campaignPageChrome.ts:297) não engole/reels(só falta a rule da lista). O conflito C184 (#1106) não foi mergeado (commit docs-only) — prosseguir. Os defers do C193 (reelMediaPath/isReelStatus) têm gatilho explícito neste item.
Abordagem recomendada
flowchart TD
L["Lista /campanha/comunicacao/reels"] -->|"loadReelLibraryPageData · status=published · sort -publishedAt"| DB[("reel + reelMedia (depth 1)")]
D["Detalhe /campanha/comunicacao/reels/:id"] -->|"loadReelDetailPageData · qualquer status"| DB
D --> P["Player 9:16 + painel de downloads"]
P -->|"video-audio ?? video · ?download=1"| M["GET .../media/kind — rota C193: só published"]
D --> K["ReelPublicationPanel"]
K -->|"POST .../publicacao"| A["setReelPublishedForActor: gate fresco + update único"]
A --> DB
D1 — Dados da página. Opções: A | B | C — Recomendação: A. Loader em src/utilities/reels/reelPageData.ts (server-only, overrideAccess: false, user, select enxuto), view models puros no dono do vocabulário src/lib/reel.ts (como lib/speechCut.ts faz) e URL curta em src/utilities/reels/reelListUrl.ts (só page, reusando campaignListUrl). Rejeitadas: B query inline nos page.tsx (espalha o where do kill switch e mata o int sem HTTP); C top-level src/utilities/reelPageData.ts (top-level é pinado por codebaseConventions.unit.spec.ts; a subpasta utilities/reels/ já é o dono criado no C193).
D2 — Render da lista. Opções: A | B | C — Recomendação: A. Grid RSC de capas 9:16, todo o card é um <Link> (sem ilha cliente), com os shells de lista (CampaignPageShell, CampaignListPendingBoundary/Results, CampaignListEmptyState, CampaignListFooter). Rejeitadas: B CampaignTable (o sistema de listas é de tabela; o design é capa-led e o job é varrer por capa/título); C busca/filtro/sort toggle no MVP (produto recomenda A; ordem fixa -publishedAt).
D3 — Transporte do kill switch. Opções: A | B | C — Recomendação: A. POST .../reels/[id]/publicacao com campaignJsonMutationRoute + setReelPublishedForActor em src/app/(campaign)/campanha/actions/reels.ts (gate fresco canReadCommunicationCatalog, find ator-escopado, um payload.update); painel cliente com useState pending + postCampaignJson + router.refresh() (precedente SpeechCutPublicationPanel.tsx:42-65, regra campanha-action-feedback). Rejeitadas: B server action + <form> (a superfície é botão com AlertDialog, não form; o wrapper JSON é convenção estrutural — codebaseConventions recusa POST fora dele); C REST direto /api/reel/:id (pula o gate fresco do ator e a mensagem de domínio; wire divergente).
D4 — Detalhe de reel não publicado. Opções: A | B | C — Recomendação: A. Detalhe abre para draft/unpublished (leitura permitida), sem player/downloads e sem nenhum href de mídia; aviso "fora da lista" com copy adaptada (arquivos guardados, sem acesso até republicar) e painel com "Publicar" (draft) / "Republicar" (unpublished). Rejeitadas: B detalhe 404 para não publicado (mataria o kill switch reversível da própria tela, contra o aceite); C player mesmo despublicado (contraria o C193 B mergeado — tocar prometeria arquivo que o servidor nega; link morto).
Componentes / mudanças
src/lib/reel.ts(editar):ReelLibraryItemViewModel/ReelDetailViewModel+toReelLibraryItemViewModel/toReelDetailViewModel(labels dereelStatusLabels/reelFeatureLabels,publishedAtLabel, disponibilidade por artefato,coverAlt); passa a consumirreelMediaPath(player/capa/downloads). Cleanup do defer C193: se ao mergeisReelStatusseguir sem call site de produção, apagá-lo + o pino emtests/unit/reel.unit.spec.ts.src/lib/campaignPaths.ts(editar):CAMPAIGN_COMMUNICATION_REELS,campaignReelDetailHref(id),campaignReelPublicationHref(id).src/lib/schemas/reel.ts(novo):reelPublicationRequestSchema(reelId: positiveRelationshipId,published: z.boolean()) eREEL_FORBIDDEN_MESSAGE('Você não tem acesso à biblioteca de reels.' — literal próprio do domínio; NÃO reusar o "acervo de falas"),REEL_NOT_FOUND_MESSAGE,REEL_GENERIC_ERROR_MESSAGE.src/utilities/reels/reelListUrl.ts(novo):parse/serialize/resolvesó depage(canônico viaresolveListUrl),buildReelListHref,REEL_PAGE_SIZE = 24.src/utilities/reels/reelPageData.ts(novo):loadReelLibraryPageData(where fixo{ status: { equals: 'published' } },sort: '-publishedAt', depth 1, select título/feature/status/cover/publishedAt) eloadReelDetailPageData(qualquer status;ReelNotFoundError→notFound()na página; depth 1).src/utilities/campaignPageActor.ts(editar): rename mecânico do gate'speechCatalog'→'communicationCatalog'(tipo + doc + condição). Verificado: 7 chamadas em 6 páginas (comunicacao/page.tsx:12,acervo/page.tsx:34,acervo/[id]/page.tsx:36,52,cortes/page.tsx:35,cortes/[id]/page.tsx:37,57) + comentários (actions/speech.ts:265,tests/e2e/fixtures/campaignHttpTest.ts:125).src/app/(campaign)/campanha/actions/reels.ts(novo):setReelPublishedForActor— schema parse,getCampaignActionContext, gate fresco por role,payload.findator-escopado (não encontrado →REEL_NOT_FOUND_MESSAGE),payload.update({ status: published ? 'published' : 'unpublished' }). Sem transação (uma coleção/um documento; o hookstampReelPublishedAtre-carimbapublishedAt—Reel.ts:51-59); mesmo precedentespeech.ts:297-320.- Rotas novas em
src/app/(campaign)/campanha/(app)/comunicacao/reels/:page.tsx(lista;campaignPageMetadataFromCatalog('reels');requireCampaignPageActor({ gate: 'communicationCatalog' })),[id]/page.tsx(detalhe: chrome próprio,ReelStatusBadge,ReelPlayer,ReelDownloadPanel,ReelTranscriptCard,ReelPublicationPanel),[id]/publicacao/route.ts(POSTviacampaignJsonMutationRoute+safeMessages),[id]/types.ts(ReelPublicationResponse). src/components/campaign/reels/(novos):ReelLibraryList.tsx,ReelLibraryCard.tsx(capa via<img>apontando à rota C193 — não usarnext/imageotimizado: o otimizador busca a fonte server-side sem o cookiecampaign-tokene receberia 404),ReelStatusBadge.tsx,ReelPlayer.tsx(<video controls playsInline preload="metadata">, fontevideo-audio ?? video, poster cover; barra custom do artefato, se mantida, é decisão do designer no port),ReelDownloadPanel.tsx(primário?download=1; secundários disponíveis viram<a>; indisponíveis, texto desabilitado — cena 06),ReelTranscriptCard.tsx(+ botão de copiar reusando o donosrc/lib/copyFeedback.ts, parametrizado comsubject: 'link' | 'text', default preserva os call sites),ReelPublicationPanel.tsx.src/components/campaign/shell/nav.ts(editar): 3º sub-item{ title: 'Reels', href: CAMPAIGN_COMMUNICATION_REELS }; atualizar comentário "dois destinations".src/lib/campaignPageChrome.ts(editar): catalogreels: { title: 'Reels', subtitle: 'Tutoriais prontos para a assessoria baixar e publicar fora do site.' }+ rule exata da lista (antes do regex de/acervo/[^/]+por vizinhança; detalhe resolvenullsem rule nova — regex verificada).- Migration: nenhuma. Access/Consent: nenhum helper novo; leituras
overrideAccess: false+user; semConsent(mídia de staff, sem PII). A rota de mídia C193 fica intocada. - UI: Impeccable C. Fonte visual:
docs/plans/reels-biblioteca-ui-design.html. Odesignerestá adaptando em paralelo: (1) cena 07 sem prometer acesso ao arquivo (remove "acessíveis por este link" — C193 B); (2) sem duração (não há campo; o00:28sai); (3) kill switch por qualquer um dos 3 papéis. Portar só o artefato adaptado; mudar estrutura visual sem o designer é defeito.
Dados → forma (se aplicável)
N/A — a intenção declara "não vou apresentar dados": inventário operacional (capa/título/status), sem métrica/gráfico. A forma é a lista ordenada por -publishedAt com paginação.
Fases verificáveis
- Contrato puro + fiação (unit). ~0.5 dia: VMs e paths;
reelListUrl; chrome; nav; rename do gate. Unit:tests/unit/reel.unit.spec.ts(editar),reelListUrl.unit.spec.ts(novo),campaignPageChrome.unit.spec.ts(editar: lista 'Reels', detalhenull),campaignNav.unit.spec.ts(editar: pin exato dos 3 sub-itens em:100-127),campaignPageActorGate.unit.spec.ts(editar:communicationCatalogpassa communicator/coordinator/candidate; advisor →/campanha, leader →/campanha/meus-contatos). Verde:pnpm test:unit. - Dados + lista + detalhe (port do artefato). ~0.5 dia:
reelPageData.ts, páginas e componentes. Int novotests/int/reelLibrary.int.spec.ts(mocknext/cache+getCampaignActionContextcomospeechCut.int.spec.ts:22-31): lista sópublishede ordenada, canonicalização depage, matriz de papéis do loader, detalhe em qualquer status,ReelNotFoundError. Aceite visual contra o artefato adaptado. - Kill switch. ~0.25 dia: schema, action, route, painel, types; int do action/rota (unpublish some da lista, republish re-carimba
publishedAtvia hook, negado → mensagem de domínio, inexistente → não encontrado). SemrevalidatePath: páginas de/campanhasão dinâmicas e o reel não temunstable_cache(C193 dispensourevalidateDocumentById); o painel fazrouter.refresh()como o de cortes. - E2E + gates. ~0.5 dia:
tests/e2e/campaignReel.e2e.spec.tsespelhandocampaignSpeechCut.e2e.spec.ts:257-355— lista/detalhe 200 com título e hrefs?download=1, GET do download comContent-Disposition: attachment, unpublish some da lista + detalhe em estado despublicado com "Republicar", republish volta, advisor/leader redirecionados e rota 400; manifestscripts/lib/e2e-affected-manifest.mjs(entry${CAMPAIGN_APP}/comunicacao: prefixessrc/components/campaign/reels,src/utilities/reels,src/lib/reele speccampaignReel; entrysrc/lib/schemas+campaignReel). Curated intocado: C194 não toca pathisHighRisk(sem migração/collection/global/harness) e o PR rodaselectedcom o spec novo mapeado; o full fica no verify do deploy. Changelogdocs/changelog/2026-09-18-c194.md;pnpm gate:fast; e2e local (pnpm test:e2e:affected+ spec novo);pnpm push; PRCloses #1153.
Rabbit holes / Não escopo (engenharia)
- Upload/edição/substituição de mídia ou retry na UI (C195 é o ingest; trocar = substituir via admin).
- Busca, filtros, sort toggle, "baixar tudo" em zip; thumbnails/duration derivados (não há campo).
- Cache (
unstable_cache) do reel ou revalidação por hook — C193 dispensou com trigger de revisitação. - Tocar a rota de mídia C193,
Media.read,reelMedia,Consentou migração. - Melhorar chrome/nav além dos 3 pontos; qualquer estrutura visual sem o
designer.
Riscos e mitigação
- C184 (#1106) no mesmo diff de nav/teste. Não mergeado (docs-only); se mergear antes, rebase — a colisão é só o pin dos sub-itens.
- Rename do gate com superfície ampla. Mecânico (6 arquivos, só strings de tipo/condição/comentário) + unit novo cobrindo a decisão; manifest da vertical acorda os specs de comunicação.
- Lista vazar não publicado ou detalhe prometer arquivo morto. Where fixo no loader + int da matriz; VM só expõe URL de artefato quando
status === 'published' && disponível; a rota C193 já responde 404. - Kill switch mentir por cache. Sem cache no caminho; resposta da rota de mídia é
private, no-store;router.refresh()do painel. - E2E novo não ser selecionado no PR. Prefixos mapeados + nome pinado por
tests/unit/e2eAffectedManifest.unit.spec.ts:24-32; execução local explícita antes do push. - Copy errada no negado. Literal do domínio ("biblioteca de reels"), não o de acervo de falas.
Hard-stops (aprovação humana explícita)
- Schema/migração: este item não toca o modelo; se algo exigir campo novo, parar e reabrir (C193 fechou).
- Semântica do arquivo (C193 B):
draft/unpublished→ 404 no serving; se o produto quiser link direto do despublicado, reabrir — não mudar a rota. - Design: só portar o artefato adaptado; mudança de estrutura visual sem o
designeré defeito (fail-closed). - Entrega:
pnpm pushcanônico e PRCloses #1153; sem push direto.
Aceite de engenharia
- Aceite de produto da intenção coberto: lista só publicados; detalhe 9:16 + downloads condicionais (primário sem áudio, com áudio, mp3,
.srt, transcrição); despublicar some e não apaga, republicar reativa; só os 3 papéis (fail-closed); sem publicação em rede; site público/cards idênticos. - Invariantes AGENTS/engineering-standards:
overrideAccess: false+user; single-collection write sem transação (justificado); semrevalidatePath(justificado); semConsent/migração;Media.reade rota C193 intocados; "edit the owner, don't twin" (chrome/nav/actor/copyFeedback/paths estendidos); identificadores em inglês, copy pt-BR; sem top-level novo emutilities/. - Testes: unit (VMs, URL, chrome, nav, gate), int (loader + kill switch + despublicado), e2e (
campaignReel) listados verdes; manifest atualizado;pnpm gate:fastepnpm pushlimpos.
Explicitamente fora (skips, descartes e defers deste triage)
- Defer (fixtures int):
tests/int/reelLibrary.int.spec.tsduplicacreateMedia/createReeldetests/int/reel.int.spec.ts(2 call sites, shapes distintos — C193 devolve{reel, video}+captions, C194 devolveReel+transcript). Gatilho: 3ª suíte/call site de fixtures de reel. - Defer (prewarm e2e): as rotas novas de reels ficam fora da lista de prewarm do
tests/e2e/setup.e2e.spec.ts(as irmãs do acervo/cortes também estão; CI em prod mode compila). Gatilho: flake recorrente de cold-compile em dev nas rotas de reels. - Defer (copy do vazio): o artefato
docs/plans/reels-biblioteca-ui-design.htmlainda mostra o jargão "Sem CTA…" que a entrega trocou por copy de usuário; a crítica final dodesignerdecide a copy canônica do artefato. - Descarte:
ReelPlayer/ReelDownloadPanelrecebendo o VM inteiro (precedenteSpeechCutPlayer);src/lib/reel.tsmisturando vocabulário e VMs (precedentespeechCut.ts).
Self-score de decision-quality
4.5/5. (1) As quatro decisões caras têm alternativas honestas com rejeição ancorada em file:line; (2) cabe no appetite herdado (~1.75 dia, sem migração, reuso máximo de shells/precedente); (3) rabbit holes nomeados; (4) depth check: loader do dono utilities/reels/, action espelhando speech.ts, wrapper campaignJsonMutationRoute, copyFeedback como dono, chrome/nav/paths/actor estendidos; (5) o aceite de produto permanece intacto — inclusive a decisão B do C193 e o kill switch reversível. Perde 0,5 porque o port depende do artefato do designer já adaptado (copy do despublicado, sem duração) — registrado como dependência/gate, não como lacuna.