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.
This commit is contained in:
Francisco Solla 2026-09-18 18:50:43 -03:00
parent bdaf3c7cbb
commit f5f412a529
43 changed files with 2156 additions and 95 deletions

View file

@ -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.

View file

@ -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 `<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.

View file

@ -288,7 +288,7 @@
</div>
<div class="p-3">
<h4 class="text-sm font-medium leading-5">Como criar seu card de apoio</h4>
<p class="mt-1.5 text-xs text-muted-foreground">Cards · 00:28</p>
<p class="mt-1.5 text-xs text-muted-foreground">Cards de apoio</p>
<p class="mt-1 text-xs text-muted-foreground">Publicado hoje</p>
<a
href="#detalhe-desktop"
@ -314,7 +314,7 @@
<h4 class="text-sm font-medium leading-5">
Moldura para foto: passo a passo
</h4>
<p class="mt-1.5 text-xs text-muted-foreground">Cards · 00:34</p>
<p class="mt-1.5 text-xs text-muted-foreground">Cards de apoio</p>
<p class="mt-1 text-xs text-muted-foreground">Publicado em —</p>
<a
href="#"
@ -338,7 +338,7 @@
</div>
<div class="p-3">
<h4 class="text-sm font-medium leading-5">Baixe seu card no celular</h4>
<p class="mt-1.5 text-xs text-muted-foreground">Cards · 00:21</p>
<p class="mt-1.5 text-xs text-muted-foreground">Cards de apoio</p>
<p class="mt-1 text-xs text-muted-foreground">Publicado em —</p>
<a
href="#"
@ -362,7 +362,7 @@
</div>
<div class="p-3">
<h4 class="text-sm font-medium leading-5">Título do reel</h4>
<p class="mt-1.5 text-xs text-muted-foreground">Funcionalidade · 00:30</p>
<p class="mt-1.5 text-xs text-muted-foreground">Funcionalidade</p>
<p class="mt-1 text-xs text-muted-foreground">Publicado em —</p>
<a
href="#"
@ -431,7 +431,9 @@
<span
class="inline-flex h-5 items-center rounded-full bg-primary px-2 text-xs text-white"
>Publicado</span
><span class="text-xs text-muted-foreground">Cards · 00:28 · publicado hoje</span>
><span class="text-xs text-muted-foreground"
>Cards de apoio · publicado hoje</span
>
</div>
<h3 class="mt-2 text-lg font-medium">Como criar seu card de apoio</h3>
</div>
@ -458,7 +460,9 @@
<div class="h-1 flex-1 rounded-full bg-white/35">
<div class="h-1 w-0 rounded-full bg-primary"></div>
</div>
<span class="text-[11px]">00:28</span>
<span class="text-[11px]" aria-label="Tempo total carregado pelo vídeo"
>—:—</span
>
</div>
<span class="absolute left-3 top-3 rounded bg-black/55 px-2 py-1 text-[10px]"
>NEEDS ASSET · MP4</span
@ -606,10 +610,6 @@
<dt class="text-xs text-muted-foreground">Funcionalidade</dt>
<dd class="mt-0.5 font-medium">Cards de apoio</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">Duração</dt>
<dd class="mt-0.5">00:28</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">Publicado em</dt>
<dd class="mt-0.5">Hoje</dd>
@ -627,8 +627,8 @@
>
</div>
<p class="mt-2 text-xs leading-5 text-muted-foreground">
Despublicar tira este reel da lista, mas preserva o arquivo e o acesso interno
por link.
Despublicar tira este reel da lista e bloqueia o player e os downloads. Os
arquivos voltam a ficar disponíveis quando o reel for republicado.
</p>
<button
class="mt-3 min-h-11 w-full rounded-lg border border-border bg-background px-3 text-sm font-medium"
@ -690,7 +690,7 @@
</div>
<div class="p-2.5">
<h4 class="text-xs font-medium leading-4">Como criar seu card de apoio</h4>
<p class="mt-1 text-[11px] text-muted-foreground">Cards · 00:28</p>
<p class="mt-1 text-[11px] text-muted-foreground">Cards de apoio</p>
</div>
</article>
<article class="overflow-hidden rounded-xl border border-border">
@ -708,21 +708,21 @@
</div>
<div class="p-2.5">
<h4 class="text-xs font-medium leading-4">Moldura para foto</h4>
<p class="mt-1 text-[11px] text-muted-foreground">Cards · 00:34</p>
<p class="mt-1 text-[11px] text-muted-foreground">Cards de apoio</p>
</div>
</article>
<article class="overflow-hidden rounded-xl border border-border">
<div class="cover-placeholder aspect-[9/16] grayscale"></div>
<div class="p-2.5">
<h4 class="text-xs font-medium leading-4">Baixe seu card</h4>
<p class="mt-1 text-[11px] text-muted-foreground">Cards · 00:21</p>
<p class="mt-1 text-[11px] text-muted-foreground">Cards de apoio</p>
</div>
</article>
<article class="overflow-hidden rounded-xl border border-border">
<div class="cover-placeholder aspect-[9/16] opacity-80"></div>
<div class="p-2.5">
<h4 class="text-xs font-medium leading-4">Título do reel</h4>
<p class="mt-1 text-[11px] text-muted-foreground">Funcionalidade · 00:30</p>
<p class="mt-1 text-[11px] text-muted-foreground">Funcionalidade</p>
</div>
</article>
</div>
@ -763,7 +763,7 @@
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-primary px-2 py-0.5 text-xs text-white"
>Publicado</span
><span class="text-xs text-muted-foreground">Cards · 00:28</span>
><span class="text-xs text-muted-foreground">Cards de apoio</span>
</div>
<h3 class="mt-2 text-lg font-medium">Como criar seu card de apoio</h3>
<div
@ -841,7 +841,8 @@
>
</div>
<p class="mt-2 text-xs leading-5 text-muted-foreground">
Despublicar tira este reel da lista, sem apagar os arquivos.
Despublicar tira este reel da lista e bloqueia o player e os downloads. Os
arquivos voltam ao republicar.
</p>
<button
class="mt-3 min-h-11 w-full rounded-lg border border-border bg-background px-3 text-sm font-medium"
@ -877,7 +878,7 @@
Sem becos sem saída
</h2>
</div>
<div class="grid gap-5 lg:grid-cols-3">
<div class="grid items-start gap-5 lg:grid-cols-3">
<article
class="overflow-hidden rounded-xl border border-border bg-background frame-shadow"
>
@ -920,7 +921,7 @@
<div class="p-5">
<div class="flex items-center gap-2">
<span class="rounded-full bg-primary px-2 py-0.5 text-xs text-white">Publicado</span
><span class="text-xs text-muted-foreground">Cards · 00:28</span>
><span class="text-xs text-muted-foreground">Cards de apoio</span>
</div>
<h3 class="mt-2 text-base font-medium">Como criar seu card de apoio</h3>
<a
@ -963,20 +964,20 @@
<div class="flex items-center gap-2">
<span class="rounded-full border border-border px-2 py-0.5 text-xs"
>Despublicado</span
><span class="text-xs text-muted-foreground">Acesso interno preservado</span>
><span class="text-xs text-muted-foreground">Cards de apoio</span>
</div>
<h3 class="mt-3 text-base font-medium">Como criar seu card de apoio</h3>
<div class="mt-4 rounded-lg bg-muted p-3">
<p class="text-sm font-medium">Fora da lista</p>
<p class="mt-1 text-xs leading-5 text-muted-foreground">
Este reel não aparece na biblioteca, mas os arquivos continuam guardados e
acessíveis por este link interno.
Este reel não aparece na biblioteca. O registro e os arquivos continuam guardados,
mas nenhuma mídia é servida enquanto estiver despublicado.
</p>
</div>
<section class="mt-4 rounded-xl border border-border p-4">
<h4 class="text-xs font-medium text-muted-foreground">Publicação na biblioteca</h4>
<p class="mt-2 text-xs leading-5 text-muted-foreground">
Republicar devolve o reel à lista. Nenhum arquivo será recriado.
Republicar devolve o reel à lista e libera novamente o player e os downloads.
</p>
<button
class="mt-3 min-h-11 w-full rounded-lg bg-primary px-3 text-sm font-semibold text-white"
@ -984,6 +985,64 @@
Republicar reel
</button>
</section>
<section class="mt-4 overflow-hidden rounded-xl border border-border">
<div class="flex min-h-14 items-center gap-3 bg-muted/35 px-3">
<div
class="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground"
>
<svg
class="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m10 9 5 3-5 3Z" />
<path d="m3 3 18 18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">Player indisponível</p>
<p class="text-xs text-muted-foreground">Disponível após republicar.</p>
</div>
<span class="text-xs text-muted-foreground">Bloqueado</span>
</div>
<div
class="flex min-h-14 items-center gap-3 border-t border-border bg-muted/35 px-3"
>
<div
class="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground"
>
<svg
class="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M5 21h14" />
<path d="m3 3 18 18" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">Downloads indisponíveis</p>
<p class="text-xs text-muted-foreground">Nenhum link de arquivo é exibido.</p>
</div>
<span class="text-xs text-muted-foreground">Bloqueados</span>
</div>
</section>
<section class="mt-4 rounded-xl border border-border p-4">
<h4 class="text-xs font-medium text-muted-foreground">Transcrição / roteiro</h4>
<p class="mt-2 text-sm leading-6">
<span class="font-medium">[Abertura]</span> Quer mostrar que está com Solla?
</p>
<p class="mt-1 text-xs leading-5 text-muted-foreground">
O texto permanece disponível porque não é um arquivo de mídia.
</p>
</section>
</div>
</article>
</div>
@ -1000,8 +1059,10 @@
placeholders explícitos; nenhum dado de terceiro foi usado.
</p>
<p class="mt-1">
<strong>Em aberto no gate:</strong> quem opera o kill switch e se o link direto do
despublicado permanece acessível. Esta proposta representa a recomendação A/A do plano.
<strong>Contrato C193 incorporado:</strong> communicator, coordinator e candidate podem
operar o kill switch. Despublicado preserva o registro, mas bloqueia player e downloads;
os arquivos voltam ao republicar. O tempo total mostrado pelo player vem do próprio vídeo,
não de um campo do catálogo.
</p>
</footer>
</main>

View file

@ -262,9 +262,14 @@ export const E2E_AFFECTED_MANIFEST = [
// Zod input schemas surface in the browser through form flows; the
// public-site and newsletter specs exercise those flows end to end. C167
// adds the cut schemas, whose HTTP contract is the cut spec; C168's library
// mutations live in the same `speech` actions file.
prefixes: ['src/lib/schemas', 'src/app/(campaign)/campanha/actions/speech.ts'],
specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut'],
// mutations live in the same `speech` actions file; C194 adds the reel
// schemas and its kill-switch action.
prefixes: [
'src/lib/schemas',
'src/app/(campaign)/campanha/actions/speech.ts',
'src/app/(campaign)/campanha/actions/reels.ts',
],
specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut', 'campaignReel'],
},
{
// Web Push client — the opt-in toast mounts on the campaign shell, so a
@ -299,18 +304,22 @@ export const E2E_AFFECTED_MANIFEST = [
},
{
// C154 — the communication vertical: the acervo search/filters/detail and
// the `speechCatalog` role gate. `src/lib/speech*` carries the pure
// the `communicationCatalog` role gate. `src/lib/speech*` carries the pure
// search/highlight modules the RSC list renders with. C167 adds the cut
// routes/player/dialog and the public cut page below.
// routes/player/dialog and the public cut page below. C194 adds the reel
// library (list/detail/downloads/kill switch) to the same vertical.
prefixes: [
`${CAMPAIGN_APP}/comunicacao`,
'src/components/campaign/speech',
'src/components/campaign/reels',
'src/utilities/speech',
'src/utilities/reels',
'src/lib/speech',
'src/lib/reel',
// The role predicates drive the vertical gate and the assistant surfaces.
'src/lib/campaignRoles',
],
specs: ['campaignSpeechAcervo', 'campaignSpeechCut'],
specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel'],
},
{
// C167 — the unlisted public page of a cut: 200 with the stored file and

View file

@ -33,7 +33,7 @@ export async function generateMetadata({ params }: SpeechDetailPageProps) {
if (!speechId) return campaignPageMetadata({ title: 'Fala' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'speechCatalog' }),
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
@ -49,7 +49,7 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
const [{ id }, query, user, payload] = await Promise.all([
params,
searchParams,
requireCampaignPageActor({ gate: 'speechCatalog' }),
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const speechId = strictDecimalInteger(id)

View file

@ -34,7 +34,7 @@ export async function generateMetadata({ params }: SpeechCutDetailPageProps) {
if (!cutId) return campaignPageMetadata({ title: 'Corte' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'speechCatalog' }),
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
@ -54,7 +54,7 @@ export async function generateMetadata({ params }: SpeechCutDetailPageProps) {
export default async function SpeechCutDetailPage({ params }: SpeechCutDetailPageProps) {
const [{ id }, user, payload] = await Promise.all([
params,
requireCampaignPageActor({ gate: 'speechCatalog' }),
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const cutId = strictDecimalInteger(id)

View file

@ -32,7 +32,7 @@ type SpeechCutLibraryPageProps = {
/** C168 — the cut library: what was already cut, newest first, searchable. */
export default async function SpeechCutLibraryPage({ searchParams }: SpeechCutLibraryPageProps) {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'speechCatalog' }),
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])

View file

@ -31,7 +31,7 @@ type SpeechAcervoPageProps = {
export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPageProps) {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'speechCatalog' }),
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])

View file

@ -9,6 +9,6 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
* stable destination and future vertical items have a home.
*/
export default async function CommunicationHomePage() {
await requireCampaignPageActor({ gate: 'speechCatalog' })
await requireCampaignPageActor({ gate: 'communicationCatalog' })
redirect(CAMPAIGN_COMMUNICATION_ACERVO)
}

View file

@ -0,0 +1,29 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/Empty'
import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
export default function ReelNotFoundPage() {
return (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyTitle>Reel não encontrado</EmptyTitle>
<EmptyDescription>
O reel pode ter sido removido ou você não tem acesso a ele.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href={CAMPAIGN_COMMUNICATION_REELS}>Voltar à biblioteca de reels</Link>
</Button>
</EmptyContent>
</Empty>
)
}

View file

@ -0,0 +1,138 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { ReelDownloadPanel } from '@/components/campaign/reels/ReelDownloadPanel'
import { ReelPlayer } from '@/components/campaign/reels/ReelPlayer'
import { ReelPublicationPanel } from '@/components/campaign/reels/ReelPublicationPanel'
import { ReelStatusBadge } from '@/components/campaign/reels/ReelStatusBadge'
import { ReelTranscriptCard } from '@/components/campaign/reels/ReelTranscriptCard'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadReelDetailPageData, ReelNotFoundError } from '@/utilities/reels/reelPageData'
type ReelDetailPageProps = {
params: Promise<{ id: string }>
}
export async function generateMetadata({ params }: ReelDetailPageProps) {
const { id } = await params
const reelId = strictDecimalInteger(id)
if (!reelId) return campaignPageMetadata({ title: 'Reel' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
try {
const reel = await loadReelDetailPageData(payload, user, reelId)
return campaignPageMetadata({ title: reel.title })
} catch {
return campaignPageMetadata({ title: 'Reel' })
}
}
/**
* C194 — one reel: the 9:16 player, every artifact the production delivered
* and the kill switch. Unpublished reels stay reachable here (so the switch
* can be reversed) but the player and the downloads are withheld — the C193
* media route serves only `published`.
*/
export default async function ReelDetailPage({ params }: ReelDetailPageProps) {
const [{ id }, user, payload] = await Promise.all([
params,
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const reelId = strictDecimalInteger(id)
if (!reelId) notFound()
const reel = await loadReelDetailPageData(payload, user, reelId).catch((error) => {
if (error instanceof ReelNotFoundError) notFound()
throw error
})
return (
<CampaignPageShell>
<SetCampaignPageChrome chrome={{ title: reel.title }} />
<div className="flex flex-col gap-4">
<Button asChild variant="ghost" className="-ml-2 min-h-10 self-start">
<Link href={CAMPAIGN_COMMUNICATION_REELS}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar aos reels
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<ReelStatusBadge status={reel.status} />
<span className="text-xs text-muted-foreground">
{reel.featureLabel}
{reel.publishedAtLabel ? <> · publicado em {reel.publishedAtLabel}</> : null}
</span>
</div>
<h1 className="mt-2 text-lg font-medium">{reel.title}</h1>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)_280px]">
<div>
<ReelPlayer reel={reel} />
</div>
<div className="min-w-0">
{reel.canServeMedia ? null : (
<div className="mb-4 rounded-lg bg-muted p-3">
<p className="text-sm font-medium">Fora da lista</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Este reel não aparece na biblioteca. O registro e os arquivos continuam guardados,
mas nenhuma mídia é servida enquanto ele não estiver publicado.
</p>
</div>
)}
<ReelDownloadPanel reel={reel} />
{reel.transcript ? (
<div className="mt-4">
<ReelTranscriptCard transcript={reel.transcript} />
</div>
) : null}
</div>
<aside className="space-y-4">
<section className="rounded-xl border border-border p-4">
<h2 className="text-xs font-medium text-muted-foreground">Sobre o reel</h2>
<dl className="mt-3 space-y-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Funcionalidade</dt>
<dd className="mt-0.5 font-medium">{reel.featureLabel}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Publicado em</dt>
<dd className="mt-0.5">{reel.publishedAtLabel ?? 'Ainda não publicado'}</dd>
</div>
</dl>
</section>
<ReelPublicationPanel reelId={reel.id} status={reel.status} />
<p className="rounded-lg bg-muted px-3 py-2 text-xs leading-5 text-muted-foreground">
Nada é publicado no Instagram por esta tela.
</p>
</aside>
</div>
</div>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { setReelPublishedForActor } from '@/app/(campaign)/campanha/actions/reels'
import {
REEL_FORBIDDEN_MESSAGE,
REEL_GENERIC_ERROR_MESSAGE,
REEL_NOT_FOUND_MESSAGE,
reelPublicationRequestSchema,
} from '@/lib/schemas/reel'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { ReelPublicationResponse } from '../types'
export const dynamic = 'force-dynamic'
export const POST = campaignJsonMutationRoute(
{
bodySchema: reelPublicationRequestSchema,
safeMessages: [REEL_FORBIDDEN_MESSAGE, REEL_NOT_FOUND_MESSAGE],
genericMessage: REEL_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const reel = await setReelPublishedForActor(body)
return NextResponse.json<ReelPublicationResponse>({ status: 'success', reel })
},
)

View file

@ -0,0 +1,12 @@
import type { ReelStatus } from '@/lib/reel'
/** C194 — the JSON envelope of the kill-switch route. */
export type ReelPublicationResponse =
| {
status: 'success'
reel: {
id: number
status: ReelStatus
}
}
| { status: 'error'; message: string }

View file

@ -0,0 +1,19 @@
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
/** C194 — grid-shaped skeleton for the reel library and its detail route. */
export default function ReelLibraryLoading() {
return (
<CampaignPageShell aria-label="Carregando biblioteca de reels" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-5 w-80 max-w-full" />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="aspect-[9/16] w-full rounded-xl" />
))}
</div>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,77 @@
import config from '@payload-config'
import { ClapperboardIcon } from 'lucide-react'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { ReelLibraryList } from '@/components/campaign/reels/ReelLibraryList'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { buildReelListHref } from '@/utilities/reels/reelListUrl'
import { loadReelLibraryPageData } from '@/utilities/reels/reelPageData'
export const metadata = campaignPageMetadataFromCatalog('reels')
type ReelLibraryPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/** C194 — the reel library: published tutorials, newest publication first. */
export default async function ReelLibraryPage({ searchParams }: ReelLibraryPageProps) {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const data = await loadReelLibraryPageData(payload, user, searchParams)
if (data.redirectHref) redirect(data.redirectHref)
return (
<CampaignPageShell aria-label="Biblioteca de reels">
<CampaignListPendingBoundary>
<CampaignListResults>
{data.rows.length > 0 ? (
<>
<div className="mb-6 flex items-end justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">Reels publicados</h2>
<p className="mt-1 text-sm text-muted-foreground">
Abra um reel para assistir e baixar os arquivos.
</p>
</div>
<span className="text-xs text-muted-foreground">
{data.totalDocs} {data.totalDocs === 1 ? 'reel' : 'reels'}
</span>
</div>
<ReelLibraryList rows={data.rows} />
</>
) : (
<CampaignListEmptyState
icon={ClapperboardIcon}
title="Nenhum reel produzido ainda"
description="A produção dos reels acontece fora do Teqo. Quando um reel for aprovado e publicado na biblioteca, ele aparece aqui para a assessoria."
/>
)}
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="reel publicado"
plural="reels publicados"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildReelListHref(data.state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,58 @@
'use server'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import type { ReelStatus } from '@/lib/reel'
import {
REEL_FORBIDDEN_MESSAGE,
REEL_NOT_FOUND_MESSAGE,
reelPublicationRequestSchema,
type ReelPublicationRequest,
} from '@/lib/schemas/reel'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
export type ReelPublicationResult = {
id: number
status: ReelStatus
}
/**
* C194 — the kill switch of the reel library. `unpublished` takes the reel off
* the list and withholds its files (the C193 media route serves only
* `published`); `published` puts the same row back. The collection's
* `beforeChange` stamps `publishedAt` on every transition into `published`, so
* the order of the list always reflects the last publication.
*
* Single-collection, single-document write: no transaction (same precedent as
* `setSpeechCutPublishedForActor`). The fresh role gate is repeated here so a
* denied actor gets the domain message instead of a silent collection denial.
*/
export const setReelPublishedForActor = async (
input: ReelPublicationRequest,
): Promise<ReelPublicationResult> => {
const parsed = reelPublicationRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(REEL_FORBIDDEN_MESSAGE)
const current = await payload.find({
collection: 'reel',
where: { id: { equals: parsed.reelId } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
})
if (!current.docs[0]) throw new Error(REEL_NOT_FOUND_MESSAGE)
const reel = await payload.update({
collection: 'reel',
id: parsed.reelId,
data: { status: parsed.published ? 'published' : 'unpublished' },
depth: 0,
user: actor,
overrideAccess: false,
})
return { id: reel.id, status: reel.status }
}

View file

@ -262,7 +262,7 @@ export const getSpeechCutStatusForActor = async (input: {
// ---------------------------------------------------------------------------
// C168 — the cut library: edit the cut's own text and toggle the public link.
// Same fresh `speechCatalog` gate as the C167 mutations; the collection access
// Same fresh `communicationCatalog` gate as the C167 mutations; the collection access
// (canReadSpeech) and the whitelisted zod payload are the field boundary.
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,109 @@
import {
CaptionsIcon,
DownloadIcon,
FileTextIcon,
MusicIcon,
SquarePlayIcon,
type LucideIcon,
} from 'lucide-react'
import type { ReelDetailViewModel, ReelDownloadItemViewModel, ReelDownloadKind } from '@/lib/reel'
const iconByKind: Record<ReelDownloadKind, LucideIcon> = {
video: DownloadIcon,
'video-audio': SquarePlayIcon,
narration: MusicIcon,
captions: CaptionsIcon,
}
/**
* C194 — the downloads of one reel. The silent video is the primary CTA; the
* optional artifacts are grouped below with the honest reason when they were
* never produced. While the reel is unpublished every row states that the
* files return on republication (no dead links — C193 decision B).
*/
export const ReelDownloadPanel = ({ reel }: { reel: ReelDetailViewModel }) => {
const primary = reel.downloads.find((item) => item.primary)
const secondary = reel.downloads.filter((item) => !item.primary)
return (
<section className="rounded-xl border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">Downloads</h2>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Arquivos deste reel para finalizar e publicar fora do Teqo.
</p>
</div>
<DownloadIcon className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
{primary ? <PrimaryDownload item={primary} /> : null}
<div className="mt-3 divide-y divide-border rounded-lg border border-border">
{secondary.map((item) => (
<DownloadRow key={item.kind} item={item} />
))}
{reel.transcript ? (
<a href="#transcricao" className="flex min-h-12 items-center gap-3 px-3">
<FileTextIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Transcrição / roteiro</p>
<p className="text-xs text-muted-foreground">Texto simples</p>
</div>
<span className="text-xs underline underline-offset-4">Ler</span>
</a>
) : null}
</div>
</section>
)
}
const PrimaryDownload = ({ item }: { item: ReelDownloadItemViewModel }) => {
if (!item.href) {
return (
<div className="mt-4 flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border border-dashed bg-muted/35 px-4 text-sm">
<span className="font-medium">{item.label}</span>
<span className="text-xs text-muted-foreground">{item.unavailableLabel}</span>
</div>
)
}
return (
<a
href={item.href}
className="mt-4 flex min-h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground"
>
<DownloadIcon className="size-4" aria-hidden="true" />
Baixar {item.label.toLowerCase()}
</a>
)
}
const DownloadRow = ({ item }: { item: ReelDownloadItemViewModel }) => {
const Icon = iconByKind[item.kind]
if (!item.href) {
return (
<div className="flex min-h-12 items-center gap-3 bg-muted/35 px-3 opacity-65">
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.label}</p>
<p className="text-xs text-muted-foreground">{item.unavailableLabel}</p>
</div>
<span className="text-xs text-muted-foreground">Indisponível</span>
</div>
)
}
return (
<a href={item.href} className="flex min-h-12 items-center gap-3 px-3">
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.label}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
<span className="text-xs underline underline-offset-4">Baixar</span>
</a>
)
}

View file

@ -0,0 +1,43 @@
import Link from 'next/link'
import type { ReelLibraryItemViewModel } from '@/lib/reel'
/**
* C194 — one card of the reel library: the 9:16 cover leads, the title and the
* feature follow, and the whole card is the link to the detail (there is no
* second action on the card, so the "Abrir reel" affordance is visual).
*/
export const ReelLibraryCard = ({ reel }: { reel: ReelLibraryItemViewModel }) => (
<article className="group overflow-hidden rounded-xl border border-border bg-card">
<Link href={reel.detailHref} className="block focus-visible:outline-none">
<div className="relative aspect-[9/16] overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element -- the cover is served by the authenticated campaign route; the Next optimizer has no session cookie. */}
<img
src={reel.coverUrl}
alt={reel.coverAlt}
loading="lazy"
className="absolute inset-0 size-full object-cover"
/>
<span className="absolute left-3 top-3 rounded-full bg-white/95 px-2 py-1 text-[11px] font-medium text-foreground">
{reel.statusLabel}
</span>
<div
aria-hidden="true"
className="absolute inset-x-3 bottom-3 rounded-lg bg-black/65 p-3 text-white"
>
<p className="text-sm leading-4 font-semibold">{reel.title}</p>
</div>
</div>
<div className="p-3">
<h3 className="text-sm leading-5 font-medium">{reel.title}</h3>
<p className="mt-1.5 text-xs text-muted-foreground">{reel.featureLabel}</p>
{reel.publishedAtLabel ? (
<p className="mt-1 text-xs text-muted-foreground">Publicado em {reel.publishedAtLabel}</p>
) : null}
<span className="mt-3 inline-flex h-8 items-center text-xs font-medium underline underline-offset-4 group-hover:text-primary">
Abrir reel
</span>
</div>
</Link>
</article>
)

View file

@ -0,0 +1,11 @@
import { ReelLibraryCard } from '@/components/campaign/reels/ReelLibraryCard'
import type { ReelLibraryItemViewModel } from '@/lib/reel'
/** C194 — the cover grid: two columns on the phone, four on the desktop. */
export const ReelLibraryList = ({ rows }: { rows: readonly ReelLibraryItemViewModel[] }) => (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
{rows.map((reel) => (
<ReelLibraryCard key={reel.id} reel={reel} />
))}
</div>
)

View file

@ -0,0 +1,29 @@
import type { ReelDetailViewModel } from '@/lib/reel'
/**
* C194 — the 9:16 player. The source prefers the rough cut with narration
* (`video-audio`) and falls back to the silent primary video; while the reel is
* unpublished there is no source at all and the player states why (C193 kill
* switch withholds every file).
*/
export const ReelPlayer = ({ reel }: { reel: ReelDetailViewModel }) => {
if (!reel.videoSourceUrl) {
return (
<div className="mx-auto flex aspect-[9/16] w-full max-w-[330px] flex-col items-center justify-center gap-1 rounded-xl border border-dashed bg-muted/40 p-4 text-center">
<span className="text-sm font-medium">Player indisponível</span>
<p className="text-xs text-muted-foreground">{reel.mediaBlockedLabel}</p>
</div>
)
}
return (
<video
controls
playsInline
preload="metadata"
poster={reel.videoPosterUrl}
src={reel.videoSourceUrl}
className="mx-auto aspect-[9/16] w-full max-w-[330px] rounded-xl border bg-black"
/>
)
}

View file

@ -0,0 +1,131 @@
'use client'
import { ReelStatusBadge } from '@/components/campaign/reels/ReelStatusBadge'
import { usePublicationToggle } from '@/components/campaign/shared/usePublicationToggle'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { campaignReelPublicationHref } from '@/lib/campaignPaths'
import type { ReelStatus } from '@/lib/reel'
type ReelPublicationPanelProps = {
reelId: number
status: ReelStatus
}
const UNPUBLISH_WARNING =
'Despublicar tira este reel da lista e bloqueia o player e os downloads. Os arquivos voltam a ficar disponíveis quando o reel for republicado.'
const PUBLISH_WARNING =
'Republicar devolve o reel à lista e libera novamente o player e os downloads.'
/**
* C194 — the kill switch on the reel detail. Publishing puts the same row back
* on the list and reopens the files; unpublishing takes it off the list and
* withholds every file (C193 serves only `published`). Confirmation only on
* the destructive direction.
*/
export const ReelPublicationPanel = ({ reelId, status }: ReelPublicationPanelProps) => {
const { submitting, error, setPublished } = usePublicationToggle({
href: campaignReelPublicationHref(reelId),
buildBody: (published) => ({ reelId, published }),
})
return (
<section className="rounded-xl border p-4">
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">
Publicação na biblioteca
</h2>
<div className="mt-2">
<ReelStatusBadge status={status} />
</div>
{status === 'published' ? (
<>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{UNPUBLISH_WARNING}</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 min-h-11 w-full"
disabled={submitting}
>
Despublicar reel
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Despublicar este reel?</AlertDialogTitle>
<AlertDialogDescription>{UNPUBLISH_WARNING}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel type="button" className="min-h-11" disabled={submitting}>
Cancelar
</AlertDialogCancel>
<Button
type="button"
className="min-h-11"
disabled={submitting}
onClick={() => void setPublished(false)}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Despublicar
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) : null}
{status === 'unpublished' ? (
<>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{PUBLISH_WARNING}</p>
<Button
type="button"
className="mt-3 min-h-11 w-full"
disabled={submitting}
onClick={() => void setPublished(true)}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Republicar reel
</Button>
</>
) : null}
{status === 'draft' ? (
<>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
Este reel ainda não foi publicado na biblioteca.
</p>
<Button
type="button"
className="mt-3 min-h-11 w-full"
disabled={submitting}
onClick={() => void setPublished(true)}
>
{submitting ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Publicar reel
</Button>
</>
) : null}
{error ? (
<Alert variant="destructive" className="mt-3 py-2">
<AlertDescription className="text-xs">{error}</AlertDescription>
</Alert>
) : null}
</section>
)
}

View file

@ -0,0 +1,14 @@
import { Badge } from '@/components/ui/Badge'
import { reelStatusLabels, type ReelStatus } from '@/lib/reel'
type StatusBadgeVariant = 'default' | 'outline'
const variantFor = (status: ReelStatus): StatusBadgeVariant =>
status === 'published' ? 'default' : 'outline'
/** C194 — the one status chip of the reel library (list and detail). */
export const ReelStatusBadge = ({ status }: { status: ReelStatus }) => (
<Badge variant={variantFor(status)} className="font-normal">
{reelStatusLabels[status]}
</Badge>
)

View file

@ -0,0 +1,54 @@
'use client'
import { CheckIcon, CopyIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
copyFeedbackLabelsFor,
copyFeedbackLiveMessagesFor,
useCopyFeedback,
} from '@/lib/copyFeedback'
/**
* C194 — the reel's script/narration text. DB content, not a served file: it
* stays readable while the reel is unpublished. Each stored line is a block,
* the same shape the production writes.
*/
export const ReelTranscriptCard = ({ transcript }: { transcript: string }) => {
const { feedback, copy } = useCopyFeedback()
const labels = copyFeedbackLabelsFor('text')
const liveMessages = copyFeedbackLiveMessagesFor('text')
const blocks = transcript
.split(/\n+/)
.map((block) => block.trim())
.filter(Boolean)
return (
<section id="transcricao" className="scroll-mt-20 rounded-xl border border-border p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">Transcrição / roteiro</h2>
<Button
type="button"
variant="ghost"
className="min-h-10 px-2 text-xs text-muted-foreground"
onClick={() => void copy(transcript)}
>
{feedback === 'copied' ? (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
) : (
<CopyIcon data-icon="inline-start" aria-hidden="true" />
)}
{labels[feedback]}
</Button>
</div>
<div className="mt-3 space-y-3 text-sm leading-6">
{blocks.map((block, index) => (
<p key={`${index}-${block}`}>{block}</p>
))}
</div>
<span aria-live="polite" className="sr-only">
{liveMessages[feedback]}
</span>
</section>
)
}

View file

@ -0,0 +1,54 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
const PUBLICATION_ERROR_MESSAGE = 'Não foi possível atualizar a publicação.'
type PublicationToggleResponse = { status: 'success' } | { status: 'error'; message: string }
/**
* C194 — the one submit/error/refresh contract of a library publish toggle
* (cut library, reel library): POST the domain body to the mutation route,
* surface the safe domain message or the shared fallback, and refresh the RSC
* page on success so the new status renders. The panels keep their own copy,
* confirmation dialog and status branches.
*/
export const usePublicationToggle = ({
href,
buildBody,
}: {
href: string
buildBody: (published: boolean) => Record<string, unknown>
}) => {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const setPublished = async (published: boolean): Promise<void> => {
setSubmitting(true)
setError(null)
try {
const { ok, payload } = await postCampaignJson<PublicationToggleResponse>(
href,
buildBody(published),
)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : PUBLICATION_ERROR_MESSAGE)
return
}
router.refresh()
} catch {
setError(PUBLICATION_ERROR_MESSAGE)
} finally {
setSubmitting(false)
}
}
return { submitting, error, setPublished }
}

View file

@ -24,6 +24,7 @@ import {
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_HOME,
CAMPAIGN_COMMUNICATION_REELS,
CAMPAIGN_CONTACTS_HOME,
CAMPAIGN_UPDATES_HREF,
LEADER_CONTACTS_HOME,
@ -54,13 +55,15 @@ export type CampaignNavItem = {
}
/**
* C174 — the communication vertical's two destinations, hung under its single
* C174 — the communication vertical's destinations, hung under its single
* top-level item. Naming the array keeps both consumers (sidebar and drawer)
* from spelling the list twice.
* from spelling the list twice. C194 appended the reel library after the
* acervo and the cut library.
*/
const communicationSubItems: readonly CampaignNavSubItem[] = [
{ title: 'Acervo de falas', href: CAMPAIGN_COMMUNICATION_ACERVO },
{ title: 'Biblioteca de cortes', href: CAMPAIGN_COMMUNICATION_CORTES },
{ title: 'Reels', href: CAMPAIGN_COMMUNICATION_REELS },
]
/**

View file

@ -1,9 +1,6 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import type { SpeechCutPublicationResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/types'
import { usePublicationToggle } from '@/components/campaign/shared/usePublicationToggle'
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import {
@ -18,7 +15,6 @@ import {
} from '@/components/ui/AlertDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import type { SpeechCutStatus } from '@/lib/speechCut'
type SpeechCutPublicationPanelProps = {
@ -35,34 +31,10 @@ const UNPUBLISH_WARNING =
* takes it off the air. A cut without a stored file cannot be published.
*/
export const SpeechCutPublicationPanel = ({ cutId, status }: SpeechCutPublicationPanelProps) => {
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const setPublished = async (published: boolean) => {
setSubmitting(true)
setError(null)
try {
const { ok, payload } = await postCampaignJson<SpeechCutPublicationResponse>(
`/campanha/comunicacao/acervo/cortes/${cutId}/publicacao`,
{ cutId, published },
)
if (!ok || payload.status !== 'success') {
setError(
payload.status === 'error' ? payload.message : 'Não foi possível atualizar a publicação.',
)
return
}
router.refresh()
} catch {
setError('Não foi possível atualizar a publicação.')
} finally {
setSubmitting(false)
}
}
const { submitting, error, setPublished } = usePublicationToggle({
href: `/campanha/comunicacao/acervo/cortes/${cutId}/publicacao`,
buildBody: (published) => ({ cutId, published }),
})
return (
<section className="rounded-xl border p-4">

View file

@ -3,6 +3,7 @@ import type { Metadata } from 'next'
import {
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_REELS,
CAMPAIGN_CONTACTS_HOME,
CAMPAIGN_HOME,
CAMPAIGN_UPDATES_HREF,
@ -125,6 +126,10 @@ export const campaignPageChromeCatalog = {
title: 'Cortes',
subtitle: 'O que já foi cortado — reencontre, ajuste o texto e republique.',
},
reels: {
title: 'Reels',
subtitle: 'Tutoriais do site prontos para a assessoria baixar e publicar fora do Teqo.',
},
} as const satisfies Record<string, CampaignPageChrome | null>
const resolveCatalogEntry = (
@ -293,6 +298,12 @@ const pathRules: PathRule[] = [
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_CORTES,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.cortes),
},
{
// C194 — the reel library list. The `/<id>` detail sets its own title
// through `SetCampaignPageChrome`, so it falls through to `null` here.
match: (pathname) => pathname === CAMPAIGN_COMMUNICATION_REELS,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.reels),
},
{
match: (pathname) => /^\/campanha\/comunicacao\/acervo\/[^/]+$/.test(pathname),
resolve: () => null,

View file

@ -28,6 +28,17 @@ export const CAMPAIGN_COMMUNICATION_ACERVO = '/campanha/comunicacao/acervo' as c
/** C168 — library of cuts made from the acervo (list + `/<id>` detail). */
export const CAMPAIGN_COMMUNICATION_CORTES = '/campanha/comunicacao/acervo/cortes' as const
/** C194 — private library of reels (list + `/<id>` detail). */
export const CAMPAIGN_COMMUNICATION_REELS = '/campanha/comunicacao/reels' as const
/** C194 — internal detail of one reel (id is immutable). */
export const campaignReelDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_REELS}/${id}`
/** C194 — JSON endpoint the detail's kill switch calls to publish/unpublish. */
export const campaignReelPublicationHref = (id: number): string =>
`${campaignReelDetailHref(id)}/publicacao`
/** C168/C174 — internal detail of one cut in the library (id is immutable). */
export const campaignSpeechCutDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_CORTES}/${id}`

View file

@ -6,18 +6,48 @@ const COPY_FEEDBACK_RESET_MS = 2000
export type CopyFeedback = 'idle' | 'copied' | 'error'
export const copyFeedbackLabels: Record<CopyFeedback, string> = {
idle: 'Copiar link',
copied: 'Link copiado',
error: 'Não foi possível copiar',
/**
* C194 — what is being copied changes the wording. `link` is the share
* controls' default; `text` serves the reel transcript.
*/
export type CopySubject = 'link' | 'text'
const copyFeedbackLabelsBySubject: Record<CopySubject, Record<CopyFeedback, string>> = {
link: {
idle: 'Copiar link',
copied: 'Link copiado',
error: 'Não foi possível copiar',
},
text: {
idle: 'Copiar texto',
copied: 'Texto copiado',
error: 'Não foi possível copiar',
},
}
export const copyFeedbackLiveMessages: Record<CopyFeedback, string> = {
idle: '',
copied: 'Link copiado.',
error: 'Não foi possível copiar o link.',
const copyFeedbackLiveMessagesBySubject: Record<CopySubject, Record<CopyFeedback, string>> = {
link: {
idle: '',
copied: 'Link copiado.',
error: 'Não foi possível copiar o link.',
},
text: {
idle: '',
copied: 'Texto copiado.',
error: 'Não foi possível copiar o texto.',
},
}
export const copyFeedbackLabelsFor = (subject: CopySubject): Record<CopyFeedback, string> =>
copyFeedbackLabelsBySubject[subject]
export const copyFeedbackLiveMessagesFor = (subject: CopySubject): Record<CopyFeedback, string> =>
copyFeedbackLiveMessagesBySubject[subject]
/** The link wording the existing share controls import (unchanged). */
export const copyFeedbackLabels = copyFeedbackLabelsBySubject.link
export const copyFeedbackLiveMessages = copyFeedbackLiveMessagesBySubject.link
/**
* The one copy-to-clipboard feedback contract of the share controls (C166 share
* sheet, C167 cut share kit): the label per state, the screen-reader live

View file

@ -5,6 +5,9 @@
* authenticated media route and the C194 library share this module.
*/
import { campaignReelDetailHref } from '@/lib/campaignPaths'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
/** Upload collection of the artifacts; also its local disk directory in dev. */
export const REEL_MEDIA_SLUG = 'reelMedia'
@ -64,3 +67,190 @@ export const reelMediaPath = (reelId: number, kind: ReelMediaKind): string =>
/** Only a published reel serves its files; `draft`/`unpublished` answer 404. */
export const canServeReelMedia = (status: ReelStatus): boolean => status === 'published'
const isReelFeature = (value: unknown): value is ReelFeature =>
REEL_FEATURES.includes(value as ReelFeature)
// ---------------------------------------------------------------------------
// C194 — the library view models. Same shape as `lib/speechCut.ts`: the pure
// builders take the lean record the loader selected and return exactly what
// the list/detail render — the artifact URLs only exist when the kill switch
// says the file can be served.
// ---------------------------------------------------------------------------
type ReelMediaRef = number | { alt?: string | null } | null | undefined
/**
* What the view models read. Structurally satisfied by a `Reel` document with
* depth 1 (and by the lean `select` of the list loader) — defined here so
* `lib/` stays free of `@/payload-types`, same reason `speechCut.ts` has its
* own record type.
*/
export type ReelRecordForView = {
id: number
title: string
feature: string
status: string
publishedAt?: string | null
transcript?: string | null
video?: ReelMediaRef
videoWithAudio?: ReelMediaRef
narrationAudio?: ReelMediaRef
captions?: ReelMediaRef
cover?: ReelMediaRef
}
/** Every artifact a person can take out of the library (the cover is not one). */
export type ReelDownloadKind = Exclude<ReelMediaKind, 'cover'>
const REEL_DOWNLOAD_KINDS = [
'video',
'video-audio',
'narration',
'captions',
] as const satisfies readonly ReelDownloadKind[]
const reelDownloadLabels: Record<ReelDownloadKind, { label: string; description: string }> = {
video: { label: 'Vídeo sem áudio', description: 'MP4 · arquivo principal' },
'video-audio': { label: 'Vídeo com áudio', description: 'Rascunho · MP4' },
narration: { label: 'Narração', description: 'MP3' },
captions: { label: 'Legendas', description: 'Arquivo .srt' },
}
/** Shown in place of the link when the artifact was never produced. */
export const reelDownloadUnavailableLabels: Record<ReelDownloadKind, string> = {
video: 'Ainda não disponível.',
'video-audio': 'Ainda não disponível.',
narration: 'A narração será adicionada depois.',
captions: 'A legenda ainda não foi gerada.',
}
/** Every file is withheld while the reel is unpublished (C193 kill switch). */
export const REEL_MEDIA_BLOCKED_LABEL = 'Disponível após republicar.'
/** A draft was never published; its files appear with the first publication. */
export const REEL_MEDIA_DRAFT_LABEL = 'Disponível após publicar.'
export type ReelLibraryItemViewModel = {
id: number
title: string
featureLabel: string
status: ReelStatus
statusLabel: string
coverUrl: string
coverAlt: string
publishedAtLabel: string | null
detailHref: string
}
export type ReelDownloadItemViewModel = {
kind: ReelDownloadKind
label: string
description: string
/** The primary file the design's CTA leads with (always the silent video). */
primary: boolean
/** Null when the artifact is missing or the kill switch withholds the file. */
href: string | null
/** What the row says instead of a link when `href` is null. */
unavailableLabel: string | null
}
export type ReelDetailViewModel = ReelLibraryItemViewModel & {
/** Script/narration text: DB content, not a served file — kept when unpublished. */
transcript: string | null
/** False when the reel is `draft`/`unpublished` — no player, no download links. */
canServeMedia: boolean
/** Why the media is withheld while `canServeMedia` is false (draft ≠ unpublished). */
mediaBlockedLabel: string
/** `video-audio` when the rough cut with narration exists, else the silent video. */
videoSourceUrl: string | null
videoPosterUrl: string
downloads: ReelDownloadItemViewModel[]
}
const normalizeReelStatus = (status: string): ReelStatus =>
isReelStatus(status) ? status : 'draft'
const hasMedia = (value: ReelMediaRef): boolean => value !== null && value !== undefined
const mediaAlt = (value: ReelMediaRef, fallback: string): string => {
if (typeof value !== 'object' || value === null) return fallback
const alt = value.alt?.trim()
return alt ? alt : fallback
}
const publishedAtLabel = (iso: string | null | undefined): string | null => {
if (!iso) return null
const instant = new Date(iso)
if (Number.isNaN(instant.getTime())) return null
return formatBahiaDateTimeLabel(iso)
}
const reelCover = (record: ReelRecordForView): { coverUrl: string; coverAlt: string } => ({
coverUrl: reelMediaPath(record.id, 'cover'),
coverAlt: mediaAlt(record.cover, record.title),
})
/** One card of the library list (published rows only — the loader filters). */
export const toReelLibraryItemViewModel = (record: ReelRecordForView): ReelLibraryItemViewModel => {
const status = normalizeReelStatus(record.status)
return {
id: record.id,
title: record.title,
featureLabel: isReelFeature(record.feature)
? reelFeatureLabels[record.feature]
: record.feature,
status,
statusLabel: reelStatusLabels[status],
...reelCover(record),
publishedAtLabel: publishedAtLabel(record.publishedAt),
detailHref: campaignReelDetailHref(record.id),
}
}
/**
* One reel with its artifacts. The media URLs are null unless the reel is
* published: the kill switch withholds the player and every download, while
* the title/feature/transcript stay readable (C193 decision B).
*/
export const toReelDetailViewModel = (record: ReelRecordForView): ReelDetailViewModel => {
const base = toReelLibraryItemViewModel(record)
const canServeMedia = canServeReelMedia(base.status)
const transcript = record.transcript?.trim()
const mediaBlockedLabel =
base.status === 'draft' ? REEL_MEDIA_DRAFT_LABEL : REEL_MEDIA_BLOCKED_LABEL
const unavailableLabelFor = (kind: ReelDownloadKind): string | null => {
if (!canServeMedia) return mediaBlockedLabel
return hasMedia(record[reelMediaFieldByKind[kind]]) ? null : reelDownloadUnavailableLabels[kind]
}
const downloads = REEL_DOWNLOAD_KINDS.map((kind): ReelDownloadItemViewModel => {
const { label, description } = reelDownloadLabels[kind]
return {
kind,
label,
description,
primary: kind === 'video',
href:
canServeMedia && hasMedia(record[reelMediaFieldByKind[kind]])
? `${reelMediaPath(record.id, kind)}?download=1`
: null,
unavailableLabel: unavailableLabelFor(kind),
}
})
return {
...base,
transcript: transcript ? transcript : null,
canServeMedia,
mediaBlockedLabel,
videoSourceUrl: canServeMedia
? reelMediaPath(record.id, hasMedia(record.videoWithAudio) ? 'video-audio' : 'video')
: null,
videoPosterUrl: base.coverUrl,
downloads,
}
}

21
src/lib/schemas/reel.ts Normal file
View file

@ -0,0 +1,21 @@
import { z } from 'zod'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
/** The communication vertical refused the actor — the reel library's own literal. */
export const REEL_FORBIDDEN_MESSAGE = 'Você não tem acesso à biblioteca de reels.'
/** The reel does not exist (or the actor cannot read it). */
export const REEL_NOT_FOUND_MESSAGE = 'Reel não encontrado.'
/** Transport/storage failure (or anything unmapped). */
export const REEL_GENERIC_ERROR_MESSAGE =
'Não foi possível atualizar a publicação. Verifique seu acesso e tente novamente.'
/** C194 — the kill switch body: which reel and where it should land. */
export const reelPublicationRequestSchema = z.object({
reelId: positiveRelationshipId,
published: z.boolean(),
})
export type ReelPublicationRequest = z.infer<typeof reelPublicationRequestSchema>

View file

@ -14,7 +14,12 @@ import { getCampaignUser, getCampaignUserWithAvatar } from '@/utilities/campaign
/** Exactly the non-null actor `getCampaignUser` proves (keeps its `email` refinement). */
export type CampaignPageActor = NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader' | 'writable' | 'speechCatalog'
export type CampaignPageGate =
| 'staff'
| 'unrestricted'
| 'noLeader'
| 'writable'
| 'communicationCatalog'
export { LEADER_CONTACTS_HOME }
@ -33,7 +38,8 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
* - 'writable' → advisor with Edição `somente_leitura` goes to `/campanha`
* (C142 — write destinations must not be offered to a
* read-only advisor; the server already rejects the write);
* - 'speechCatalog'→ advisor/leader lose the communication vertical (C154);
* - 'communicationCatalog' → advisor/leader lose the communication vertical
* (C154; C194 generalized the name from `speechCatalog`);
* communicator/coordinator/candidate pass, the same
* predicate the collection access uses.
*
@ -69,9 +75,12 @@ export const requireCampaignPageActor = async (
const denyRedirect = redirectTo ?? roleHome[user.role] ?? '/campanha'
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
// C154 — the speech catalog vertical: communicator/coordinator/candidate,
// the same predicate the collection access uses.
if (gate === 'speechCatalog' && !canReadCommunicationCatalog(user.role)) redirect(denyRedirect)
// C154/C194 — the communication vertical (acervo, cortes and reels):
// communicator/coordinator/candidate, the same predicate the collection
// access uses.
if (gate === 'communicationCatalog' && !canReadCommunicationCatalog(user.role)) {
redirect(denyRedirect)
}
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect)
if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect)
if (gate === 'writable' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)

View file

@ -0,0 +1,60 @@
/**
* C194 — URL contract of the reel library list (`/campanha/comunicacao/reels`):
* pagination only. The MVP has no search or filters (intention: the acervo is
* small and the job is scanning covers), so `page` is the whole state.
*/
import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
import {
buildListHref,
firstValue,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export const reelPageSize = 24
export type ReelListState = {
page: number
}
const reelListParamNames = ['page'] as const
const reelListParamNameSet = new Set<string>(reelListParamNames)
export const parseReelListParams = (params: RawSearchParams): ReelListState => ({
page: strictDecimalInteger(firstValue(params.page)) ?? 1,
})
const serializeCanonicalReelListSearchParams = (state: ReelListState): URLSearchParams => {
const params = new URLSearchParams()
if (state.page > 1) params.set('page', String(state.page))
return params
}
const buildReelListSearchParams = (state: ReelListState, page = state.page): URLSearchParams =>
serializeCanonicalReelListSearchParams(
parseReelListParams({
page: String(page),
}),
)
export const buildReelListHref = (state: ReelListState, page: number): string =>
buildListHref(state, buildReelListSearchParams, CAMPAIGN_COMMUNICATION_REELS, page)
export const resolveReelListUrl = (
params: RawSearchParams,
totalPages?: number,
): {
state: ReelListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: reelListParamNameSet,
parse: parseReelListParams,
buildSearchParams: buildReelListSearchParams,
basePath: CAMPAIGN_COMMUNICATION_REELS,
totalPages,
})

View file

@ -0,0 +1,120 @@
import 'server-only'
import type { Payload } from 'payload'
import {
toReelDetailViewModel,
toReelLibraryItemViewModel,
type ReelDetailViewModel,
type ReelLibraryItemViewModel,
} from '@/lib/reel'
import { REEL_NOT_FOUND_MESSAGE } from '@/lib/schemas/reel'
import type { CampaignUser } from '@/payload-types'
import { type RawSearchParams } from '@/utilities/campaignListUrl'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import {
parseReelListParams,
reelPageSize,
resolveReelListUrl,
type ReelListState,
} from '@/utilities/reels/reelListUrl'
/**
* Lean `select` of the library grid: only the fields the card view model
* renders. Payload does not reflect `select` in the result type, so the int
* test is the guard.
*/
const reelListSelect = {
title: true,
feature: true,
status: true,
publishedAt: true,
cover: true,
} as const
/** Everything the detail view model reads, including the optional artifacts. */
const reelDetailSelect = {
title: true,
feature: true,
status: true,
publishedAt: true,
transcript: true,
video: true,
videoWithAudio: true,
narrationAudio: true,
captions: true,
cover: true,
} as const
export const ReelNotFoundError = createEntityNotFoundError('Reel', REEL_NOT_FOUND_MESSAGE)
export type ReelLibraryPageData = {
rows: ReelLibraryItemViewModel[]
state: ReelListState
redirectHref?: string
totalDocs: number
totalPages: number
}
/**
* C194 — the library list: published reels only (the kill switch takes the row
* off the list without deleting it), newest publication first. Access is the
* collection's own `canReadReel` (communicator/coordinator/candidate).
*/
export const loadReelLibraryPageData = async (
payload: Payload,
user: CampaignUser,
searchParams: Promise<RawSearchParams> | RawSearchParams,
): Promise<ReelLibraryPageData> => {
const rawSearchParams = await searchParams
const state = parseReelListParams(rawSearchParams)
const result = await payload.find({
collection: 'reel',
depth: 1,
limit: reelPageSize,
page: state.page,
sort: '-publishedAt',
where: { status: { equals: 'published' } },
select: reelListSelect,
user,
overrideAccess: false,
})
const resolvedUrl = resolveReelListUrl(rawSearchParams, result.totalPages)
return {
rows: result.docs.map((reel) => toReelLibraryItemViewModel(reel)),
state: resolvedUrl.state,
redirectHref: resolvedUrl.redirectHref,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
}
}
/**
* C194 — one reel of the library, any status: the detail is where the kill
* switch is reversed, so an unpublished reel stays reachable while its media
* does not (C193 decision B withholds the player and the downloads).
*/
export const loadReelDetailPageData = async (
payload: Payload,
user: CampaignUser,
reelId: number,
): Promise<ReelDetailViewModel> => {
const result = await payload.find({
collection: 'reel',
where: { id: { equals: reelId } },
depth: 1,
limit: 1,
pagination: false,
select: reelDetailSelect,
user,
overrideAccess: false,
})
const reel = result.docs[0]
if (!reel) throw new ReelNotFoundError()
return toReelDetailViewModel(reel)
}

View file

@ -2,7 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import type { CampaignUser, Speech } from '@/payload-types'
import {
@ -184,7 +184,7 @@ export const loadSpeechAcervoPageData = async (
const themeRequested = state.mode === 'tema' && Boolean(state.q)
let themeTerms: readonly string[] = []
let themeUnavailable = false
if (themeRequested && canReadSpeechCatalog(user.role)) {
if (themeRequested && canReadCommunicationCatalog(user.role)) {
const expansion = await expandTheme(state.q ?? '')
if (expansion) themeTerms = expansion.terms
else themeUnavailable = true

View file

@ -0,0 +1,205 @@
import { randomUUID } from 'node:crypto'
import type { APIRequestContext } from '@playwright/test'
import { request as playwrightRequest } from '@playwright/test'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import {
assertCampaignRedirect,
assertLeaderRedirect,
expect,
rendered,
test,
} from './fixtures/campaignHttpTest.js'
/**
* C194 — the private reel library over real HTTP (browserless): the list and
* the detail render for a communication role, the downloads are served by the
* C193 authenticated media route, the kill switch takes the reel off the list
* and withholds every file (C193 decision B), and advisor/leader are denied
* both the page and the mutation route.
*
* Reels and their media are seeded through the deployed REST API (admin
* session) so the server process runs the real hooks; a Local API call from
* the runner would throw on `revalidateTag` (same reason as the sibling
* speech-cut spec).
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const FAKE_MP4 = Buffer.from('reel-library-mp4')
const FAKE_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
const createdReelIds: number[] = []
const createdMediaIds: number[] = []
const createReelMedia = async (
request: APIRequestContext,
headers: Record<string, string>,
name: string,
mimeType: string,
buffer: Buffer,
): Promise<number> => {
const media = await request.post(`${BASE_URL}/api/reelMedia`, {
headers,
multipart: {
_payload: JSON.stringify({ alt: `Artefato ${name}` }),
file: { name, mimeType, buffer },
},
})
expect(media.ok(), await media.text()).toBeTruthy()
const mediaId = ((await media.json()) as { doc: { id: number } }).doc.id
createdMediaIds.push(mediaId)
return mediaId
}
const createReel = async (
request: APIRequestContext,
headers: Record<string, string>,
input: { title: string; status: 'published' | 'unpublished' | 'draft' },
): Promise<number> => {
const suffix = randomUUID().slice(0, 8)
const video = await createReelMedia(request, headers, `reel-${suffix}.mp4`, 'video/mp4', FAKE_MP4)
const cover = await createReelMedia(request, headers, `capa-${suffix}.png`, 'image/png', FAKE_PNG)
const reel = await request.post(`${BASE_URL}/api/reel`, {
headers,
data: {
title: input.title,
feature: 'cards',
status: input.status,
video,
cover,
transcript: '[Abertura] Quer mostrar que está com Solla?',
},
})
expect(reel.ok(), await reel.text()).toBeTruthy()
const reelId = ((await reel.json()) as { doc: { id: number } }).doc.id
createdReelIds.push(reelId)
return reelId
}
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request, BASE_URL).catch(() => null)
if (!headers) return
for (const id of createdReelIds.splice(0)) {
await request.delete(`${BASE_URL}/api/reel/${id}`, { headers }).catch(() => undefined)
}
for (const id of createdMediaIds.splice(0)) {
await request.delete(`${BASE_URL}/api/reelMedia/${id}`, { headers }).catch(() => undefined)
}
})
test.describe('Reel library (C194)', () => {
test.beforeAll(async () => {
await seedTestUser()
})
test('lists, opens and downloads a published reel; the kill switch empties the list and withholds the files', async ({
campaign,
campaignRequest,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const title = `Reel da biblioteca ${randomUUID().slice(0, 8)}`
const reelId = await createReel(request, headers, { status: 'published', title })
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const list = await communicatorRequest.get('/campanha/comunicacao/reels')
expect(list.status()).toBe(200)
expect(rendered(await list.text())).toContain(title)
const detail = await communicatorRequest.get(`/campanha/comunicacao/reels/${reelId}`)
expect(detail.status()).toBe(200)
const detailHtml = rendered(await detail.text())
expect(detailHtml).toContain(title)
expect(detailHtml).toContain(`/campanha/comunicacao/reels/${reelId}/media/video?download=1`)
expect(detailHtml).toContain(`/campanha/comunicacao/reels/${reelId}/media/cover`)
expect(detailHtml).toContain('Transcrição / roteiro')
const download = await communicatorRequest.get(
`/campanha/comunicacao/reels/${reelId}/media/video?download=1`,
)
expect(download.status()).toBe(200)
expect(download.headers()['content-disposition']).toContain('attachment')
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
expect(
(await anonymous.get(`/campanha/comunicacao/reels/${reelId}/media/video`)).status(),
).toBe(404)
} finally {
await anonymous.dispose()
}
const unpublish = await communicatorRequest.post(
`/campanha/comunicacao/reels/${reelId}/publicacao`,
{ data: { reelId, published: false } },
)
expect(unpublish.status()).toBe(200)
expect(((await unpublish.json()) as { reel: { status: string } }).reel.status).toBe(
'unpublished',
)
const listAfter = rendered(
await (await communicatorRequest.get('/campanha/comunicacao/reels')).text(),
)
expect(listAfter).not.toContain(title)
const detailAfter = rendered(
await (await communicatorRequest.get(`/campanha/comunicacao/reels/${reelId}`)).text(),
)
expect(detailAfter).toContain('Republicar reel')
expect(detailAfter).toContain('Player indisponível')
expect(detailAfter).not.toContain('?download=1')
expect(detailAfter).not.toContain('<video')
const republish = await communicatorRequest.post(
`/campanha/comunicacao/reels/${reelId}/publicacao`,
{ data: { reelId, published: true } },
)
expect(republish.status()).toBe(200)
expect(((await republish.json()) as { reel: { status: string } }).reel.status).toBe('published')
const listBack = rendered(
await (await communicatorRequest.get('/campanha/comunicacao/reels')).text(),
)
expect(listBack).toContain(title)
})
test('denies advisor/leader on the library page and on the kill-switch route', async ({
campaign,
campaignRequest,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const reelId = await createReel(request, headers, {
status: 'published',
title: `Reel restrito ${randomUUID().slice(0, 8)}`,
})
for (const role of ['advisor', 'leader'] as const) {
const denied = await campaign.fixtures.createCampaignUser(role)
const deniedRequest = await campaignRequest(denied, denied.password)
if (role === 'leader') {
await assertLeaderRedirect(deniedRequest, '/campanha/comunicacao/reels')
} else {
await assertCampaignRedirect(deniedRequest, '/campanha/comunicacao/reels', '/campanha')
}
const response = await deniedRequest.post(
`/campanha/comunicacao/reels/${reelId}/publicacao`,
{ data: { reelId, published: false } },
)
expect(response.status()).toBe(400)
expect(((await response.json()) as { message: string }).message).toContain(
'não tem acesso à biblioteca de reels',
)
}
})
})

View file

@ -122,7 +122,7 @@ export const rendered = (html: string) => html.replaceAll('<!-- -->', '')
/**
* The route-gate redirect contract (OPS35 → OPS87, 4 call sites; C154
* generalized the target): `noLeader`/`staff`/`speechCatalog` gates throw
* generalized the target): `noLeader`/`staff`/`communicationCatalog` gates throw
* `redirect()` deep in the page, after the layout has started streaming — so
* the document usually answers 200 carrying Next's route-redirect meta tag
* instead of a 3xx; when the redirect beats the stream it is a real 3xx (dev

View file

@ -0,0 +1,303 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// The reel collection's hooks and the Next runtime: the library loaders do not
// cache anything, but the action context and the config import run through the
// Next server graph — same neutering as the sibling speech-cut suite.
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
unstable_cache: (fn: unknown) => fn,
}))
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
return { ...original, getCampaignActionContext: vi.fn() }
})
import { setReelPublishedForActor } from '@/app/(campaign)/campanha/actions/reels'
import { REEL_FORBIDDEN_MESSAGE, REEL_NOT_FOUND_MESSAGE } from '@/lib/schemas/reel'
import type { CampaignUser, Reel, ReelMedia } from '@/payload-types'
import config from '@/payload.config'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
loadReelDetailPageData,
loadReelLibraryPageData,
ReelNotFoundError,
} from '@/utilities/reels/reelPageData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const MP4_BYTES = Buffer.from('fixture-mp4-bytes')
const PNG_BYTES = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
let payload: Payload
const createdReelIds = new Set<number>()
const createdMediaIds = new Set<number>()
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const mockedGetContext = vi.mocked(getCampaignActionContext)
const asActor = (actor: CampaignUser) => mockedGetContext.mockResolvedValue({ payload, actor })
const createMedia = async (name: string, data: Buffer, mimetype: string): Promise<ReelMedia> => {
const media = await payload.create({
collection: 'reelMedia',
data: { alt: `Artefato ${name}` },
file: { data, mimetype, name, size: data.length },
overrideAccess: true,
})
createdMediaIds.add(media.id)
return media
}
const createReel = async ({
status = 'draft',
title = 'Reel de tutorial',
withTranscript = false,
}: {
status?: Reel['status']
title?: string
withTranscript?: boolean
} = {}): Promise<Reel> => {
const video = await createMedia(`reel-${createdReelIds.size}.mp4`, MP4_BYTES, 'video/mp4')
const cover = await createMedia(`capa-${createdReelIds.size}.png`, PNG_BYTES, 'image/png')
const reel = await payload.create({
collection: 'reel',
data: {
title,
feature: 'cards',
status,
video: video.id,
cover: cover.id,
...(withTranscript ? { transcript: ' [Abertura] Olá. ' } : {}),
},
overrideAccess: true,
})
createdReelIds.add(reel.id)
return reel
}
const setPublishedAt = async (reelId: number, iso: string): Promise<void> => {
await payload.update({
collection: 'reel',
id: reelId,
data: { publishedAt: iso },
depth: 0,
overrideAccess: true,
})
}
describe('reel library data (C194)', () => {
let communicator: CampaignUser
let coordinator: CampaignUser
let candidate: CampaignUser
let advisor: CampaignUser
let leader: CampaignUser
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
beforeEach(async () => {
const fixtures = campaignFixtures()
;[communicator, coordinator, candidate, advisor, leader] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('candidate'),
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('leader'),
])
})
afterAll(async () => {
for (const id of createdReelIds) {
await payload.delete({ collection: 'reel', id, overrideAccess: true }).catch(() => undefined)
}
for (const id of createdMediaIds) {
await payload
.delete({ collection: 'reelMedia', id, overrideAccess: true })
.catch(() => undefined)
}
})
it('lists only published reels, newest publication first, with the private cover path', async () => {
const older = await createReel({ status: 'published', title: 'Reel antigo' })
await setPublishedAt(older.id, '2026-09-01T12:00:00.000Z')
const newer = await createReel({ status: 'published', title: 'Reel novo' })
await setPublishedAt(newer.id, '2026-09-10T12:00:00.000Z')
const draft = await createReel({ status: 'draft' })
const unpublished = await createReel({ status: 'unpublished' })
const data = await loadReelLibraryPageData(payload, communicator, {})
const ids = data.rows.map((row) => row.id)
expect(ids).toContain(newer.id)
expect(ids).toContain(older.id)
expect(ids).not.toContain(draft.id)
expect(ids).not.toContain(unpublished.id)
expect(ids.indexOf(newer.id)).toBeLessThan(ids.indexOf(older.id))
expect(data.totalDocs).toBeGreaterThanOrEqual(2)
const newest = data.rows.find((row) => row.id === newer.id)
expect(newest).toMatchObject({
title: 'Reel novo',
featureLabel: 'Cards de apoio (#cards)',
status: 'published',
statusLabel: 'Publicado',
coverUrl: `/campanha/comunicacao/reels/${newer.id}/media/cover`,
detailHref: `/campanha/comunicacao/reels/${newer.id}`,
})
})
it('canonicalizes the URL and redirects an overflowing page', async () => {
const data = await loadReelLibraryPageData(payload, coordinator, {
page: 'abc',
unsupported: 'x',
})
expect(data.state.page).toBe(1)
expect(data.redirectHref).toBe('/campanha/comunicacao/reels')
})
it('denies the list to advisors and leaders', async () => {
await expect(loadReelLibraryPageData(payload, advisor, {})).rejects.toThrow()
await expect(loadReelLibraryPageData(payload, leader, {})).rejects.toThrow()
})
it('loads the detail for any status and fails closed for an unknown reel', async () => {
const published = await createReel({
status: 'published',
title: 'Reel com arquivos',
withTranscript: true,
})
const draft = await createReel({ status: 'draft' })
const detail = await loadReelDetailPageData(payload, candidate, published.id)
expect(detail.canServeMedia).toBe(true)
expect(detail.videoSourceUrl).toBe(`/campanha/comunicacao/reels/${published.id}/media/video`)
expect(detail.videoPosterUrl).toBe(`/campanha/comunicacao/reels/${published.id}/media/cover`)
expect(detail.transcript).toBe('[Abertura] Olá.')
expect(detail.downloads[0]).toMatchObject({
kind: 'video',
primary: true,
href: `/campanha/comunicacao/reels/${published.id}/media/video?download=1`,
unavailableLabel: null,
})
expect(detail.downloads[1].href).toBeNull()
const draftDetail = await loadReelDetailPageData(payload, communicator, draft.id)
expect(draftDetail.canServeMedia).toBe(false)
expect(draftDetail.videoSourceUrl).toBeNull()
expect(draftDetail.downloads.every((item) => item.href === null)).toBe(true)
await expect(loadReelDetailPageData(payload, communicator, 999_999)).rejects.toBeInstanceOf(
ReelNotFoundError,
)
})
it('denies the detail to advisors and leaders', async () => {
const reel = await createReel({ status: 'published' })
await expect(loadReelDetailPageData(payload, advisor, reel.id)).rejects.toThrow()
await expect(loadReelDetailPageData(payload, leader, reel.id)).rejects.toThrow()
})
})
describe('reel kill switch (C194)', () => {
let communicator: CampaignUser
let coordinator: CampaignUser
let advisor: CampaignUser
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
beforeEach(async () => {
const fixtures = campaignFixtures()
;[communicator, coordinator, advisor] = await Promise.all([
fixtures.createCampaignUser('communicator'),
fixtures.createCampaignUser('coordinator'),
fixtures.createCampaignUser('advisor'),
])
})
it('takes the reel off the list and withholds its media without deleting it', async () => {
const reel = await createReel({
status: 'published',
title: 'Reel do kill switch',
withTranscript: true,
})
asActor(communicator)
const result = await setReelPublishedForActor({ reelId: reel.id, published: false })
expect(result).toEqual({ id: reel.id, status: 'unpublished' })
const list = await loadReelLibraryPageData(payload, communicator, {})
expect(list.rows.map((row) => row.id)).not.toContain(reel.id)
const detail = await loadReelDetailPageData(payload, communicator, reel.id)
expect(detail.canServeMedia).toBe(false)
expect(detail.transcript).toBe('[Abertura] Olá.')
expect(detail.status).toBe('unpublished')
const stored = await payload.findByID({
collection: 'reel',
id: reel.id,
depth: 0,
overrideAccess: true,
})
const videoId = typeof reel.video === 'number' ? reel.video : reel.video.id
expect(stored.video).toBe(videoId)
})
it('republishes the same row and re-stamps the publication', async () => {
const reel = await createReel({ status: 'published', title: 'Reel republicado' })
asActor(coordinator)
await setReelPublishedForActor({ reelId: reel.id, published: false })
await setPublishedAt(reel.id, '2020-01-01T00:00:00.000Z')
const result = await setReelPublishedForActor({ reelId: reel.id, published: true })
expect(result.status).toBe('published')
const stored = await payload.findByID({
collection: 'reel',
id: reel.id,
depth: 0,
overrideAccess: true,
})
expect(stored.publishedAt).not.toBe('2020-01-01T00:00:00.000Z')
expect(new Date(stored.publishedAt ?? 0).getTime()).toBeGreaterThan(
new Date('2020-01-01T00:00:00.000Z').getTime(),
)
const list = await loadReelLibraryPageData(payload, coordinator, {})
expect(list.rows.map((row) => row.id)).toContain(reel.id)
})
it('refuses the kill switch for a denied role and reports a missing reel', async () => {
const reel = await createReel({ status: 'published' })
asActor(advisor)
await expect(setReelPublishedForActor({ reelId: reel.id, published: false })).rejects.toThrow(
REEL_FORBIDDEN_MESSAGE,
)
asActor(communicator)
await expect(setReelPublishedForActor({ reelId: 999_999, published: false })).rejects.toThrow(
REEL_NOT_FOUND_MESSAGE,
)
})
})

View file

@ -14,6 +14,7 @@ import {
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_HOME,
CAMPAIGN_COMMUNICATION_REELS,
CAMPAIGN_UPDATES_HREF,
} from '@/lib/campaignPaths'
import { ORGANIZATIONS_LIST_PATH } from '@/lib/campaignQuickActionPaths'
@ -97,15 +98,20 @@ describe('communicator sidebar', () => {
})
})
describe('communication vertical sub-items (C174)', () => {
const expectedSubHrefs = [CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES]
describe('communication vertical sub-items (C174, C194)', () => {
const expectedSubHrefs = [
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_REELS,
]
it('hangs the acervo and the cut library under Comunicação for the communicator', () => {
it('hangs the acervo, the cut library and the reels under Comunicação for the communicator', () => {
const [item] = getCampaignNav('communicator')
expect(item?.subItems?.map((sub) => sub.href)).toEqual(expectedSubHrefs)
expect(item?.subItems?.map((sub) => sub.title)).toEqual([
'Acervo de falas',
'Biblioteca de cortes',
'Reels',
])
})

View file

@ -25,10 +25,11 @@ vi.mock('@/utilities/access/shared', () => ({
},
}))
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
type Actor = {
role: 'coordinator' | 'candidate' | 'advisor' | 'leader'
role: 'coordinator' | 'candidate' | 'communicator' | 'advisor' | 'leader'
visibility?: 'carteira' | 'tudo'
editing?: 'carteira' | 'tudo' | 'somente_leitura'
email: string
@ -82,6 +83,33 @@ describe('requireCampaignPageActor — gate: writable (C142)', () => {
})
})
describe('requireCampaignPageActor — gate: communicationCatalog (C154, renamed by C194)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('lets the three communication roles through', async () => {
for (const role of ['communicator', 'coordinator', 'candidate'] as const) {
mocks.getCampaignUser.mockResolvedValue(actor(role))
const user = await requireCampaignPageActor({ gate: 'communicationCatalog' })
expect(user.role).toBe(role)
expect(mocks.redirect).not.toHaveBeenCalled()
}
})
it('sends the advisor to the campaign home', async () => {
mocks.getCampaignUser.mockResolvedValue(actor('advisor'))
await requireCampaignPageActor({ gate: 'communicationCatalog' })
expect(mocks.redirect).toHaveBeenCalledWith('/campanha')
})
it('sends the leader to their contacts tool', async () => {
mocks.getCampaignUser.mockResolvedValue(actor('leader'))
await requireCampaignPageActor({ gate: 'communicationCatalog' })
expect(mocks.redirect).toHaveBeenCalledWith(LEADER_CONTACTS_HOME)
})
})
describe('requireCampaignPageActor — withAvatar option', () => {
beforeEach(() => {
vi.clearAllMocks()

View file

@ -69,6 +69,14 @@ describe('campaignPageChrome', () => {
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo/42', 'communicator')).toBeNull()
})
it('resolves the reel library list and leaves the detail title to the page (C194)', () => {
expect(resolveCampaignPageChrome('/campanha/comunicacao/reels', 'communicator')).toEqual({
title: 'Reels',
subtitle: 'Tutoriais do site prontos para a assessoria baixar e publicar fora do Teqo.',
})
expect(resolveCampaignPageChrome('/campanha/comunicacao/reels/42', 'communicator')).toBeNull()
})
it('separates staff contatos from the leader meus-contatos (C139)', () => {
expect(resolveCampaignPageChrome('/campanha/contatos', 'coordinator')).toEqual({
title: 'Contatos',

View file

@ -7,12 +7,18 @@ import {
isReelMediaKind,
isReelStatus,
REEL_FEATURES,
REEL_MEDIA_BLOCKED_LABEL,
REEL_MEDIA_DRAFT_LABEL,
REEL_MEDIA_KINDS,
REEL_STATUSES,
reelDownloadUnavailableLabels,
reelFeatureLabels,
reelMediaFieldByKind,
reelMediaPath,
reelStatusLabels,
toReelDetailViewModel,
toReelLibraryItemViewModel,
type ReelRecordForView,
} from '@/lib/reel'
import {
REEL_MEDIA_FALLBACK_MIME_TYPE,
@ -134,3 +140,103 @@ describe('reel media response rules (C193)', () => {
expect(headers.get('Content-Type')).toBe('video/mp4')
})
})
const baseRecord: ReelRecordForView = {
id: 42,
title: 'Como criar seu card de apoio',
feature: 'cards',
status: 'published',
publishedAt: '2026-09-18T17:00:00.000Z',
transcript: ' [Abertura] Quer mostrar que está com Solla? ',
video: { alt: 'Vídeo do tutorial' },
cover: { alt: 'Capa do tutorial' },
}
describe('reel view models (C194)', () => {
it('builds the library card with the private media paths', () => {
const item = toReelLibraryItemViewModel(baseRecord)
expect(item).toEqual({
id: 42,
title: 'Como criar seu card de apoio',
featureLabel: 'Cards de apoio (#cards)',
status: 'published',
statusLabel: 'Publicado',
coverUrl: '/campanha/comunicacao/reels/42/media/cover',
coverAlt: 'Capa do tutorial',
publishedAtLabel: '18/09/2026 às 14:00',
detailHref: '/campanha/comunicacao/reels/42',
})
})
it('falls back to the title when the cover is an id and to the raw feature when unknown', () => {
const item = toReelLibraryItemViewModel({
...baseRecord,
feature: 'wallpapers',
cover: 7,
})
expect(item.coverAlt).toBe('Como criar seu card de apoio')
expect(item.featureLabel).toBe('wallpapers')
})
it('offers the primary silent video and the existing variants as downloads', () => {
const detail = toReelDetailViewModel({
...baseRecord,
videoWithAudio: { alt: 'Rascunho' },
})
expect(detail.canServeMedia).toBe(true)
expect(detail.videoSourceUrl).toBe('/campanha/comunicacao/reels/42/media/video-audio')
expect(detail.videoPosterUrl).toBe('/campanha/comunicacao/reels/42/media/cover')
expect(detail.transcript).toBe('[Abertura] Quer mostrar que está com Solla?')
expect(detail.downloads.map((item) => item.kind)).toEqual([
'video',
'video-audio',
'narration',
'captions',
])
const [video, withAudio, narration, captions] = detail.downloads
expect(video).toMatchObject({
primary: true,
href: '/campanha/comunicacao/reels/42/media/video?download=1',
unavailableLabel: null,
})
expect(withAudio).toMatchObject({
href: '/campanha/comunicacao/reels/42/media/video-audio?download=1',
unavailableLabel: null,
})
expect(narration.href).toBeNull()
expect(narration.unavailableLabel).toBe(reelDownloadUnavailableLabels.narration)
expect(captions.href).toBeNull()
expect(captions.unavailableLabel).toBe(reelDownloadUnavailableLabels.captions)
})
it('withholds player and downloads while the kill switch is off (C193 decision B)', () => {
const detail = toReelDetailViewModel({
...baseRecord,
status: 'unpublished',
videoWithAudio: { alt: 'Rascunho' },
narrationAudio: { alt: 'Narração' },
})
expect(detail.canServeMedia).toBe(false)
expect(detail.videoSourceUrl).toBeNull()
expect(detail.transcript).toBe('[Abertura] Quer mostrar que está com Solla?')
expect(detail.mediaBlockedLabel).toBe(REEL_MEDIA_BLOCKED_LABEL)
for (const item of detail.downloads) {
expect(item.href).toBeNull()
expect(item.unavailableLabel).toBe(REEL_MEDIA_BLOCKED_LABEL)
}
})
it('tells a draft the files arrive with the first publication, not a republication', () => {
const detail = toReelDetailViewModel({ ...baseRecord, status: 'draft' })
expect(detail.canServeMedia).toBe(false)
expect(detail.mediaBlockedLabel).toBe(REEL_MEDIA_DRAFT_LABEL)
expect(detail.downloads.every((item) => item.unavailableLabel === REEL_MEDIA_DRAFT_LABEL)).toBe(
true,
)
})
})