teqo-backup/docs/plans/reels-biblioteca-impl.md
Francisco Solla 448f3845a2 feat(C194): biblioteca de reels na vertical Comunicação (#1153)
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.
2026-09-18 21:54:37 -03:00

102 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/reels` lista 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ó a `communicator`/`coordinator`/`candidate`, fail-closed.
- **O que NÃO negociar:** arquivos privados (rota C193 é a única porta; `Media.read` intocado); 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`); sem `Consent` novo; 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`/`unpublished` nã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
```mermaid
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 de `reelStatusLabels`/`reelFeatureLabels`, `publishedAtLabel`, disponibilidade por artefato, `coverAlt`); passa a consumir `reelMediaPath` (player/capa/downloads). **Cleanup do defer C193:** se ao merge `isReelStatus` seguir sem call site de produção, apagá-lo + o pino em `tests/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()`) e `REEL_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/resolve` só de `page` (canônico via `resolveListUrl`), `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) e `loadReelDetailPageData` (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.find` ator-escopado (não encontrado → `REEL_NOT_FOUND_MESSAGE`), `payload.update({ status: published ? 'published' : 'unpublished' })`. **Sem transação** (uma coleção/um documento; o hook `stampReelPublishedAt` re-carimba `publishedAt` — `Reel.ts:51-59`); mesmo precedente `speech.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` (`POST` via `campaignJsonMutationRoute` + `safeMessages`), `[id]/types.ts` (`ReelPublicationResponse`).
- **`src/components/campaign/reels/`** (novos): `ReelLibraryList.tsx`, `ReelLibraryCard.tsx` (capa via `<img>` apontando à rota C193 — **não** usar `next/image` otimizado: o otimizador busca a fonte server-side sem o cookie `campaign-token` e receberia 404), `ReelStatusBadge.tsx`, `ReelPlayer.tsx` (`<video controls playsInline preload="metadata">`, fonte `video-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 dono `src/lib/copyFeedback.ts`, parametrizado com `subject: '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): catalog `reels: { 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 resolve `null` sem rule nova — regex verificada).
- **Migration:** nenhuma. **Access/Consent:** nenhum helper novo; leituras `overrideAccess: false` + `user`; sem `Consent` (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`. O `designer` está 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; o `00:28` sai); (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
1. **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', detalhe `null`), `campaignNav.unit.spec.ts` (editar: pin exato dos 3 sub-itens em `:100-127`), `campaignPageActorGate.unit.spec.ts` (editar: `communicationCatalog` passa communicator/coordinator/candidate; advisor → `/campanha`, leader → `/campanha/meus-contatos`). Verde: `pnpm test:unit`.
2. **Dados + lista + detalhe (port do artefato).** ~0.5 dia: `reelPageData.ts`, páginas e componentes. Int novo `tests/int/reelLibrary.int.spec.ts` (mock `next/cache` + `getCampaignActionContext` como `speechCut.int.spec.ts:22-31`): lista só `published` e ordenada, canonicalização de `page`, matriz de papéis do loader, detalhe em qualquer status, `ReelNotFoundError`. Aceite visual contra o artefato adaptado.
3. **Kill switch.** ~0.25 dia: schema, action, route, painel, types; int do action/rota (unpublish some da lista, republish re-carimba `publishedAt` via hook, negado → mensagem de domínio, inexistente → não encontrado). Sem `revalidatePath`: páginas de `/campanha` são dinâmicas e o reel não tem `unstable_cache` (C193 dispensou `revalidateDocumentById`); o painel faz `router.refresh()` como o de cortes.
4. **E2E + gates.** ~0.5 dia: `tests/e2e/campaignReel.e2e.spec.ts` espelhando `campaignSpeechCut.e2e.spec.ts:257-355` — lista/detalhe 200 com título e hrefs `?download=1`, GET do download com `Content-Disposition: attachment`, unpublish some da lista + detalhe em estado despublicado com "Republicar", republish volta, advisor/leader redirecionados e rota 400; manifest `scripts/lib/e2e-affected-manifest.mjs` (entry `${CAMPAIGN_APP}/comunicacao`: prefixes `src/components/campaign/reels`, `src/utilities/reels`, `src/lib/reel` e spec `campaignReel`; entry `src/lib/schemas` + `campaignReel`). **Curated intocado:** C194 não toca path `isHighRisk` (sem migração/collection/global/harness) e o PR roda `selected` com o spec novo mapeado; o full fica no verify do deploy. Changelog `docs/changelog/2026-09-18-c194.md`; `pnpm gate:fast`; e2e local (`pnpm test:e2e:affected` + spec novo); `pnpm push`; PR `Closes #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`, `Consent` ou 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)
1. **Schema/migração:** este item não toca o modelo; se algo exigir campo novo, parar e reabrir (C193 fechou).
2. **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.
3. **Design:** só portar o artefato adaptado; mudança de estrutura visual sem o `designer` é defeito (fail-closed).
4. **Entrega:** `pnpm push` canônico e PR `Closes #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); sem `revalidatePath` (justificado); sem `Consent`/migração; `Media.read` e 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 em `utilities/`.
- [ ] Testes: unit (VMs, URL, chrome, nav, gate), int (loader + kill switch + despublicado), e2e (`campaignReel`) listados verdes; manifest atualizado; `pnpm gate:fast` e `pnpm push` limpos.
## Explicitamente fora (skips, descartes e defers deste triage)
- **Defer (fixtures int):** `tests/int/reelLibrary.int.spec.ts` duplica `createMedia`/`createReel` de `tests/int/reel.int.spec.ts` (2 call sites, shapes distintos — C193 devolve `{reel, video}`+captions, C194 devolve `Reel`+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.html` ainda mostra o jargão "Sem CTA…" que a entrega trocou por copy de usuário; a crítica final do `designer` decide a copy canônica do artefato.
- **Descarte:** `ReelPlayer`/`ReelDownloadPanel` recebendo o VM inteiro (precedente `SpeechCutPlayer`); `src/lib/reel.ts` misturando vocabulário e VMs (precedente `speechCut.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.