From 448f3845a24b368b9181c5e4b66283bb2e953248 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 18 Sep 2026 18:50:43 -0300 Subject: [PATCH] =?UTF-8?q?feat(C194):=20biblioteca=20de=20reels=20na=20ve?= =?UTF-8?q?rtical=20Comunica=C3=A7=C3=A3o=20(#1153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/changelog/2026-09-18-c194.md | 1 + docs/plans/reels-biblioteca-impl.md | 102 ++++++ docs/plans/reels-biblioteca-ui-design.html | 113 +++++-- scripts/lib/e2e-affected-manifest.mjs | 21 +- .../(app)/comunicacao/acervo/[id]/page.tsx | 4 +- .../comunicacao/acervo/cortes/[id]/page.tsx | 4 +- .../(app)/comunicacao/acervo/cortes/page.tsx | 2 +- .../(app)/comunicacao/acervo/page.tsx | 2 +- .../campanha/(app)/comunicacao/page.tsx | 2 +- .../comunicacao/reels/[id]/not-found.tsx | 29 ++ .../(app)/comunicacao/reels/[id]/page.tsx | 138 ++++++++ .../reels/[id]/publicacao/route.ts | 27 ++ .../(app)/comunicacao/reels/[id]/types.ts | 12 + .../(app)/comunicacao/reels/loading.tsx | 19 ++ .../campanha/(app)/comunicacao/reels/page.tsx | 77 +++++ src/app/(campaign)/campanha/actions/reels.ts | 58 ++++ src/app/(campaign)/campanha/actions/speech.ts | 2 +- .../campaign/reels/ReelDownloadPanel.tsx | 109 +++++++ .../campaign/reels/ReelLibraryCard.tsx | 43 +++ .../campaign/reels/ReelLibraryList.tsx | 11 + src/components/campaign/reels/ReelPlayer.tsx | 29 ++ .../campaign/reels/ReelPublicationPanel.tsx | 131 ++++++++ .../campaign/reels/ReelStatusBadge.tsx | 14 + .../campaign/reels/ReelTranscriptCard.tsx | 54 ++++ .../campaign/shared/usePublicationToggle.ts | 54 ++++ src/components/campaign/shell/nav.ts | 7 +- .../speech/SpeechCutPublicationPanel.tsx | 38 +-- src/lib/campaignPageChrome.ts | 11 + src/lib/campaignPaths.ts | 11 + src/lib/copyFeedback.ts | 46 ++- src/lib/reel.ts | 190 +++++++++++ src/lib/schemas/reel.ts | 21 ++ src/utilities/campaignPageActor.ts | 19 +- src/utilities/reels/reelListUrl.ts | 60 ++++ src/utilities/reels/reelPageData.ts | 120 +++++++ tests/e2e/campaignReel.e2e.spec.ts | 205 ++++++++++++ tests/e2e/fixtures/campaignHttpTest.ts | 2 +- tests/int/reelLibrary.int.spec.ts | 303 ++++++++++++++++++ tests/unit/campaignNav.unit.spec.ts | 12 +- tests/unit/campaignPageActorGate.unit.spec.ts | 30 +- tests/unit/campaignPageChrome.unit.spec.ts | 8 + tests/unit/reel.unit.spec.ts | 106 ++++++ 42 files changed, 2154 insertions(+), 93 deletions(-) create mode 100644 docs/changelog/2026-09-18-c194.md create mode 100644 docs/plans/reels-biblioteca-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/page.tsx create mode 100644 src/app/(campaign)/campanha/actions/reels.ts create mode 100644 src/components/campaign/reels/ReelDownloadPanel.tsx create mode 100644 src/components/campaign/reels/ReelLibraryCard.tsx create mode 100644 src/components/campaign/reels/ReelLibraryList.tsx create mode 100644 src/components/campaign/reels/ReelPlayer.tsx create mode 100644 src/components/campaign/reels/ReelPublicationPanel.tsx create mode 100644 src/components/campaign/reels/ReelStatusBadge.tsx create mode 100644 src/components/campaign/reels/ReelTranscriptCard.tsx create mode 100644 src/components/campaign/shared/usePublicationToggle.ts create mode 100644 src/lib/schemas/reel.ts create mode 100644 src/utilities/reels/reelListUrl.ts create mode 100644 src/utilities/reels/reelPageData.ts create mode 100644 tests/e2e/campaignReel.e2e.spec.ts create mode 100644 tests/int/reelLibrary.int.spec.ts diff --git a/docs/changelog/2026-09-18-c194.md b/docs/changelog/2026-09-18-c194.md new file mode 100644 index 00000000..2deb0817 --- /dev/null +++ b/docs/changelog/2026-09-18-c194.md @@ -0,0 +1 @@ +**C194 — Biblioteca de reels na vertical Comunicação (2026-09-18):** a assessoria produzia os Reels de tutorial fora do Teqo e não tinha onde se servir do material pronto; o C193 registrou o modelo e os arquivos privados, e o C194 entregou a biblioteca. (1) **Lista** em `/campanha/comunicacao/reels` — grid de capas 9:16 (o card inteiro é o link), só `published`, ordenada por `-publishedAt`, com paginação pelo shell do sistema de listas e vazio honesto ("a produção acontece fora desta tela"); loader próprio `src/utilities/reels/reelPageData.ts` (Local API `overrideAccess:false` + `user`, `select` enxuto) e URL `src/utilities/reels/reelListUrl.ts` (só `page`). (2) **Detalhe** em `/campanha/comunicacao/reels/[id]` — player 9:16 (`video-audio ?? video`, time nativo), painel de downloads (CTA primário "Baixar vídeo sem áudio" com `?download=1`; vídeo com áudio, mp3 e `.srt` como linhas quando existem, com motivo honesto quando não) e transcrição/roteiro em texto com copiar (owner `copyFeedback` parametrizado por assunto) — sem duração (o modelo não tem campo; a decisão visual foi adaptada pelo `designer`, tier primário). (3) **Kill switch** no detalhe: `POST .../reels/[id]/publicacao` via `campaignJsonMutationRoute` + `setReelPublishedForActor` (gate fresco do papel, update único, sem transação, hook do C193 re-carimba `publishedAt`); despublicar tira da lista e bloqueia player/downloads na tela com "Republicar" no lugar (a rota de mídia do C193 responde 404 a `draft`/`unpublished` — decisão B herdada, sem link morto), e a transcrição (texto, não mídia) permanece legível. (4) **Acesso**: só `communicator`/`coordinator`/`candidate` (fail-closed; `advisor`/`leader` redirecionados, rota de mutação com mensagem de domínio própria). (5) **Fiação**: 3º sub-item "Reels" da vertical no `nav.ts` (teste do pin atualizado), rule da lista no `campaignPageChrome.ts` (o detalhe resolve chrome próprio), paths em `campaignPaths.ts`, e o rename mecânico do gate `speechCatalog` → `communicationCatalog` (dono `campaignPageActor.ts`; inclui a referência que o C193 deixou passar em `speechPageData.ts`, que quebrava o typecheck do `main`). (6) **Sem migration, sem Consent, sem `Media.read`/rota do C193 tocados, site público e cards idênticos.** (7) **Testes**: unit de view models (arquivos bloqueados fora de `published`, capa/alt, publicação), nav/chrome/gate; int do loader (lista só publicados e ordenada, matriz de papéis, detalhe em qualquer status, `ReelNotFoundError`) e do kill switch (despublicar não apaga, republicar re-carimba `publishedAt`, negado/inexistente); e2e novo `campaignReel` (HTTP: lista/detalhe/download, anônimo 404, unpublish sem lista e sem mídia, republicar volta, advisor/leader negados) mapeado no manifest. `pnpm gate:fast` verde + e2e local do spec novo e das mesmas superfícies (cortes/acervo) verdes. diff --git a/docs/plans/reels-biblioteca-impl.md b/docs/plans/reels-biblioteca-impl.md new file mode 100644 index 00000000..5d199d04 --- /dev/null +++ b/docs/plans/reels-biblioteca-impl.md @@ -0,0 +1,102 @@ +# 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 `` (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 + `
` (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 `` 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` (`