feat(C193): reel privado no CMS — modelo, upload privado e kill switch (Closes #1152)

This commit is contained in:
Francisco Solla 2026-09-18 17:27:22 -03:00
parent 45eef25b1a
commit bdaf3c7cbb
27 changed files with 17450 additions and 28 deletions

1
.gitignore vendored
View file

@ -57,6 +57,7 @@ next-env.d.ts
.env .env
/media /media
/reelMedia
# TSE open-data zip cache (pnpm db:seed:tse) # TSE open-data zip cache (pnpm db:seed:tse)
/data/tse/ /data/tse/

View file

@ -0,0 +1 @@
**C193 — Reel privado no CMS: modelo, arquivos privados e kill switch (2026-09-18):** a assessoria produz os Reels de tutorial fora do Teqo e eles não tinham onde existir. Entregou-se a base no `/campanha`: (1) **collection `reel`** (grupo `Comunicação`) com `title`, `feature` (lista fechada, começando `cards`), `status` `draft→published→unpublished`, e os artefatos como uploads 1:1 — `video` (MP4 sem áudio, primário, required), `videoWithAudio`, `narrationAudio`, `captions` (`.srt`), `cover` (required) — mais `transcript` em texto, `publishedAt` carimbado na transição para `published` e `createdBy` do ator; (2) **collection de upload privada `reelMedia`** separada de `media` (que é leitura pública por contrato, pinada no lockdown): `read/create/update/delete` só para `communicator`/`coordinator`/`candidate` + admin, registrada no plugin S3 (`reelMedia: true`) para não cair em disco efêmero em produção; (3) **rota autenticada de mídia** `GET /campanha/comunicacao/reels/[id]/media/[kind]` — sob `/campanha` de propósito, porque é o path do cookie `campaign-token` que `<video>`/`<img>` enviam; gate pelo mesmo predicado da vertical, Local API com `overrideAccess: false`, e **kill switch de verdade**: só `published` serve, `draft`/`unpublished` respondem 404 silencioso (sem vazar existência); streaming direto do bucket privado via `GetObjectCommand`/`Range` (helpers do `payload/internal`) com fallback de disco em dev/test, content-type allowlisted (o que não for mídia segura vira `application/octet-stream` + attachment, sem XSS no origin da campanha), `Content-Disposition` com `filename*` e `Cache-Control: private, no-store`; (4) **RBAC**: `canReadSpeechCatalog` → `canReadCommunicationCatalog` (o predicado é da vertical inteira; sem twin) com `src/utilities/access/reels.ts` (`canReadReel`/`canDeleteReel`, delete como predicado próprio) reexportado pelo barrel; (5) **migration `20260918_201009_add_reel`** (tabelas, enums, FKs, rels) aplicada local; (6) **testes**: unit do vocabulário/headers (9) e int do modelo + rota com matriz de 5 papéis, anônimo, Range 206, download, kill switch e 404s (9). Sem UI (Impeccable A), sem Consent, sem publicação em rede social e site público intocado. `pnpm gate:fast` verde (355 arquivos / 3724 testes) + knip limpo; follow-up do ramo S3 sem teste registrado em C198 (#1162).

View file

@ -0,0 +1,54 @@
# Hardening do serving privado de reels (ramo S3)
Status: rascunho
Atualizado em: 2026-09-18
Issue: #1162
Priority: P2
Impeccable: A — N/A sem UI
Design UI: N/A — sem UI
Appetite: ~½ dia eng; cobertura do ramo de produção sem tocar o contrato
Responsável: —
## Intenção
O C193 (#1152) entregou o serving privado dos artefatos de reel com dois ramos no mesmo utilitário: disco local (dev/test) e S3/Garage (produção). A suíte só exercita o ramo de disco — o caminho que roda em produção (`HeadObjectCommand` → `GetObjectCommand` com `Range`, 206/416, 404 de objeto ausente, content-type/disposition) depende de inspeção, e uma regressão passaria o CI verde. Este item fecha essa lacuna com um teste que injeta o cliente S3 (sem depender de infraestrutura real) e pina o contrato de resposta do ramo de produção.
## Contexto
- Utilitário: `src/utilities/reels/reelMediaResponse.ts` (`buildReelMediaResponse`, `openS3Object`, `openLocalObject`).
- Regras puras (já cobertas por unit): `src/lib/reelMedia.ts` (`reelMediaContentType`, `reelMediaContentDisposition`, `reelMediaHeaders`).
- Cobertura atual: `tests/int/reel.int.spec.ts` exercita a rota e o disco local; `tests/unit/reel.unit.spec.ts` cobre o puro.
- Achados que motivaram o item (triage do C193): `isMissingObject` já checava um campo inexistente (`httpStatusCode`) antes do fix da sessão; o ramo S3 ficou sem prova.
## Objetivos
- Um teste (unit ou int, sem S3 real) cobre o ramo S3 de `buildReelMediaResponse`:
- objeto existente → 200 com `Content-Length`/`Content-Type`/`Cache-Control: private, no-store`;
- `Range` válido → 206 com `Content-Range` e slice correto;
- `Range` insatisfazível → 416 com `Content-Range: bytes */<size>`;
- `NoSuchKey`/404 → 404 silencioso (sem vazar erro do SDK);
- `ContentLength` ausente → erro interno (nunca 200 enganoso).
- O teste não sobe Garage/S3: injeta um cliente falso no ponto de resolução de storage (extrair uma costura testável se necessário, sem alterar o contrato público).
- Guardrails: nenhuma mudança de comportamento observável na rota; sem migration; sem Consent; `Media.read` intocado.
## Decisões em aberto (implementação)
- **Costura de injeção:** expor um override interno (`__setReelMediaStorageForTests`?) é feio; preferir extrair `openReelMediaObject({ storage, filename, rangeHeader })` como função de módulo testável com um storage fake, mantendo `reelMediaStorage()` privado. Decidir no impl plan.
- **Camada:** unit com fake do `S3Client` (rápido, sem DB) vs int com `vi.mock('@aws-sdk/client-s3')`. Preferir unit — o ramo não toca Payload/DB.
## Fases verificáveis
1. **Costura + unit do ramo S3** — extrair o ponto de injeção e cobrir a matriz acima.
2. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo
- Subir MinIO/Garage em CI para testar integração real (custo de infra desproporcional; o contrato do SDK é estável).
- Testar o ramo de disco de novo (já pinado no `reel.int.spec.ts`).
- Mudar o desenho do streaming (roteamento por presign, proxy self-fetch) — decisão travada no impl do C193.
- C194 (#1153) e C195 (#1154) — consumidores do contrato, não deste hardening.
## Aceite de engenharia
- [ ] Ramo S3 coberto com fake, sem infra externa e sem afrouxar asserções.
- [ ] Nenhuma mudança de comportamento no serving; `pnpm gate:fast` verde.

View file

@ -0,0 +1,151 @@
# Impl: Reel privado no CMS
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1152
Intenção: docs/plans/reels-reel-privado.md
Appetite restante: herdado (~1 dia eng; um reel registrável de ponta a ponta com arquivos privados e kill switch)
## Leitura da intenção
- **Outcome:** uma collection `reel` na vertical de Comunicação registra título, funcionalidade-alvo (`cards`) e os artefatos do reel (vídeo sem áudio primário, variante com áudio, mp3 da narração, `.srt`, transcrição e capa), com `status` `draft | published | unpublished` funcionando como kill switch. Os arquivos são privados: só abrem com login de campanha; `unpublished` deixa de servi-los. O site público e os cards não mudam. C194 (biblioteca) consome o modelo e o caminho autenticado de mídia; C195 (ingestão Local API) escreve no mesmo modelo.
- **O que NÃO negociar:**
- Privacidade do arquivo: anônimo e navegador sem sessão recebem não; `Media.read` público intocado (`src/collections/Media.ts:16-21`, pino em `tests/int/collectionAccessLockdown.int.spec.ts:224-249`).
- Site público e `#cards` idênticos; nenhuma URL pública nova.
- Sem publicação automática no Instagram, sem segundo cadastro de pessoa, sem clone de voz, sem `Consent` novo (mídia interna de staff, sem PII).
- Papéis da comunicação (`communicator`/`coordinator`/`candidate`); `advisor`/`leader` negados fail-closed.
- Arquivos aceitos como enviados: sem transcode, compressão, thumb derivada ou versionamento.
- **O que reavaliar:**
- `revalidateDocumentById` no `afterChange`/`afterDelete` do `reel`: não há cache público nem `unstable_cache` do reel. Dispersável; documentado abaixo (mantido apenas se houver leitura cacheada — não há no C193).
- e2e: sem UI no C193; a cobertura de fluxo fica para o C194. Registrado como decisão, não como lacuna.
- A questão de produto "`unpublished` esconde só da listagem ou invalida o arquivo?" conflita com a recomendação do C194. O C193 **adota B (invalidar o arquivo)** e marca como decisão assumida a confirmar no gate.
## Abordagem recomendada
```mermaid
flowchart TD
A[Admin / C195 Local API] -->|create/update| R[(collection reel)]
R --> V[video → reelMedia]
R --> VA[videoWithAudio → reelMedia]
R --> N[narrationAudio → reelMedia]
R --> S[captions → reelMedia]
R --> C[cover → reelMedia]
B[Browser logado] -->|GET /campanha/comunicacao/reels/:id/media/:kind| G{getCampaignUser + canReadCommunicationCatalog}
G -->|nega| N404[404]
G -->|ok| L[Local API find reel<br/>overrideAccess:false, user]
L -->|status != published| N404
L -->|campo vazio| N404
L --> H{S3 enabled?}
H -->|sim| S3[S3Client GetObjectCommand + Range]
H -->|não| D[disco: path.resolve staticDir ou 'reelMedia']
S3 --> RESP[206/200 · Content-Type · Content-Length · Accept-Ranges<br/>Content-Disposition · Cache-Control private,no-store]
D --> RESP
```
**Opções consideradas:** A | B | C
- **Serving privado — A (recomendada):** rota Next `GET /campanha/comunicacao/reels/[id]/media/[kind]/route.ts`; gate por `getCampaignUser()` + `canReadCommunicationCatalog`; carrega o `reel` com Local API `overrideAccess: false, user`; streama o objeto direto (S3 `GetObjectCommand` com `Range`, ou disco local). Reusa `parseRangeHeader`/`getRangeRequestInfo` de `payload/internal` (os mesmos helpers do handler local/S3, `node_modules/payload/dist/uploads/endpoints/getFile.js:19,51-53`). O cookie `campaign-token` tem `path='/campanha'` (`src/utilities/campaignAuth.ts:12-13`), e a rota vive sob `/campanha` — o cookie chega; `<video>`/`<img>` não mandam header, mas a rota é gated por sessão, não por `Authorization`.
- **B — URL assinada S3 (rejeitada):** `signedDownloads` aponta para o endpoint interno (`host.docker.internal:3900`), inalcançável pelo browser.
- **C — self-fetch para `/api/reelMedia/file/...` com `Authorization: JWT` (rejeitada, mantida como revisitação):** reusaria o streaming do plugin, mas exige request HTTP do processo a si mesmo pela URL pública (túnel Cloudflare), com risco de latência/loop. Preterida.
- **Modelo — A (recomendada):** collection `reel` (group `'Comunicação'`) + collection de upload privada `reelMedia`, separada de `media`. Justificativa: `media` é o contrato de leitura pública (`Media.ts:16-21`), pino de lockdown; colocá-la atrás de login quebraria o site. Rejeitadas: **reusar `media`** (quebra leitura anônima) e **um único campo upload com arquivos mistos** (perde discriminação de artefato, tipo e downloads por `kind` que o C194 consome).
- **RBAC — A (recomendada):** renomear `canReadSpeechCatalog` → `canReadCommunicationCatalog` em `src/lib/campaignRoles.ts:25` (o predicado é da vertical de comunicação inteira; reels usam o mesmo conjunto) e atualizar ~23 referências em 7 arquivos (`actions/speech.ts`, `campaignPageActor.ts`, `findSpeechExcerpts.ts`, `poster/route.ts`, `nav.ts`, `access/speeches.ts`, `schemas/speechVod.ts`) + barrel. Rejeitada: criar um twin `canReadReelCatalog` (duplicaria o predicado — fere "edit the owner, don't twin").
- **Feature — A (recomendada):** lista fechada em `src/lib/reel.ts`, começando por `cards`; rejeitada texto livre (variação de grafia, sem agrupamento).
- **Aprovação (`draft → published`) — A (recomendada, assumida):** qualquer um dos três papéis da comunicação; rejeitada restringir a coordinator/candidate (a assessoria produz e aprova; coordinator/candidate mantêm o kill switch).
**Recomendação:** modelo `reel` + `reelMedia`, access em `src/utilities/access/reels.ts`, rename do predicado, serving por rota autenticada sob `/campanha` streamando direto do S3/disco. Sem UI.
### Componentes / mudanças
- **`Reel`** (`src/collections/Reel.ts`, novo): slug `reel`; labels singular `Reel`, plural `Reels`; `admin.group: 'Comunicação'`; `useAsTitle: 'title'`; `defaultColumns: ['title', 'feature', 'status', 'publishedAt']`; `admin.description` explicando que os arquivos são privados e que despublicar tira do ar. Campos: `title` (text, required, `maxLength` de `src/lib/reel.ts`); `feature` (select, required, options de `REEL_FEATURES`); `status` (select, required, default `draft`, index, options de `REEL_STATUSES`, `admin.description` do kill switch); `video` (upload → `reelMedia`, required — primário sem áudio); `videoWithAudio` (upload → `reelMedia`, opcional); `narrationAudio` (upload → `reelMedia`, opcional); `captions` (upload → `reelMedia`, opcional); `transcript` (textarea, opcional — roteiro/transcrição); `cover` (upload → `reelMedia`, required); `publishedAt` (date, readOnly); `systemStampedActorField({ setAccess: canSetCampaignSystemField })`. Access: `create`/`update`/`read` = `canReadReel`, `delete` = `canDeleteReel` (predicado próprio, forma de `canDeleteSpeechCut` em `src/utilities/access/speeches.ts:39-44`). Hooks: `beforeChange: [stampCampaignCreatedBy]`. **Dispensa de `revalidateDocumentById`:** o `reel` não é lido por `getCachedDocumentById`/`unstable_cache` nem renderizado em RSC público; não há tag a bustar. Reavaliar se o C194 introduzir leitura cacheada.
- **`ReelMedia`** (`src/collections/ReelMedia.ts`, novo): slug `reelMedia`; labels singular `Mídia de reel`, plural `Mídias de reel`; `admin.group: 'Comunicação'`; `upload: true`; campo `alt` (text, required); access `read`/`create`/`update` = `canReadReel`, `delete` = `canDeleteReel`. **Não** entra em nenhuma superfície pública (sem rota `/api/reelMedia/file` consumida pelo site). Justificativa da separação versus `media`: o bucket é privado via proxy, mas o access de `media` é `read: () => true`; reusá-la entregaria os arquivos a qualquer um que descubra o filename.
- **`src/lib/reel.ts`** (novo): constantes puras e client-safe — `REEL_STATUSES` (`draft|published|unpublished`), `REEL_FEATURES` (`['cards']`), `REEL_TITLE_MAX_LENGTH`, `REEL_MEDIA_SLUG`, labels pt-BR (`reelStatusLabels`, `reelFeatureLabels`) e o mapa `kind → campo` (`REEL_MEDIA_KINDS`/`reelMediaFieldByKind`). **`src/lib/reelMedia.ts`** (novo): regras puras da resposta HTTP (content type allowlisted, content disposition, headers de range/cache). Fonte única consumida pela collection, pela rota e pelos unit tests.
- **`src/lib/campaignRoles.ts`** (editar): renomear `canReadSpeechCatalog` → `canReadCommunicationCatalog` e atualizar o comentário; nenhuma mudança de comportamento.
- **`src/utilities/access/reels.ts`** (novo): `canReadReel` e `canDeleteReel`, forma de `speeches.ts` — `isPayloadAdmin(req.user)` → `true`; `getFreshCampaignUser(req)` → `canReadCommunicationCatalog(role)`; senão `false`. `canDeleteReel` é predicado próprio (nunca alias de `canReadReel`), para que ampliar leitura não conceda delete. Reexport no barrel `src/utilities/campaignAccess.ts` (bloco após `speeches`, linhas ~172-177).
- **`src/utilities/reels/reelMediaResponse.ts`** (novo, subpasta de domínio — o top-level de `src/utilities/` é pinado por `tests/unit/codebaseConventions.unit.spec.ts:405-533`): responsabilidade única de montar a resposta de streaming, separando o puro do I/O — parser dos headers (`Range`, `?download=1` → `Content-Disposition: attachment`, `Cache-Control: private, no-store`, `Content-Type`, `Content-Length`, `Accept-Ranges`) testável sem rede, e a resolução do objeto (`resolveS3StorageEnv().enabled` → `S3Client` + `GetObjectCommand`; senão `path.resolve(staticDir || 'reelMedia', filename)`), com `parseRangeHeader`/`getRangeRequestInfo` para 206/416. `@aws-sdk/client-s3` já é dependência direta (`package.json`), usada em `scripts/recover-media.mjs`.
- **`route.ts`** (`src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/media/[kind]/route.ts`, novo): `export const dynamic = 'force-dynamic'`; `getCampaignUser()` + `canReadCommunicationCatalog` (senão 404); valida `id` inteiro e `kind ∈ REEL_MEDIA_KINDS`; Local API `find` do `reel` com `overrideAccess: false, user`, `depth: 0`; **kill switch** → só serve quando `status === 'published'` (`draft`/`unpublished` → 404); resolve o upload do campo do `kind` (vazio → 404); delega ao `reelMediaResponse`. Espelha o formato do precedente `.../acervo/[id]/poster/route.ts:53-89` (rota interna, 404 silencioso para não vazar existência).
- **`payload.config.ts`** (editar): importar `Reel`/`ReelMedia`, registrar em `collections` (junto de `Media`, linhas 100-133) e adicionar `reelMedia: true` ao `s3Storage({ collections: { media: true, reelMedia: true } })` (linhas 160-178). Sem isso, `reelMedia` cai em disco local mesmo com S3 ligado — inaceitável em prod. O plugin é condicionado a `mediaStorage.enabled` (`resolveS3StorageEnv`, fail-closed).
- **Migration:** `pnpm migrate:create add_reel` — cria `reel`, `reel_media`, enums (`enum_reel_status`, `enum_reel_feature`), colunas de FK para `reel_media`/`campaign_user`, `payload_locked_documents_rels`, índices (`status`). Referência de forma: `src/migrations/20260915_122356_add_speech_cut.ts`. Registrar `.ts`+`.json`+`index.ts`. **Nunca editar migrações antigas.**
- **Access / Consent:** `src/utilities/access/reels.ts` (novo) reexportado pelo barrel; leituras sempre `overrideAccess: false` + `user`; `Media.read` intocado. **Sem `Consent`** — mídia interna de staff, sem PII; nenhum fluxo de opt-in nasce aqui.
- **UI:** Impeccable **A — N/A sem UI**. Nenhum componente, shell ou tela neste item. A rota de mídia não é tela; o C194 monta a lista/detalhe e o painel de downloads consumindo o contrato.
### Dados → forma (se aplicável)
O C194 consome o contrato; o C193 fixa a forma. Cada artefato é um `upload` 1:1 com `reelMedia`; o `kind` da URL mapeia para o campo e para o content type:
| `kind` | Campo | Obrigatório | Content-Type típico |
| ------------- | ---------------- | ----------- | -------------------------- |
| `video` | `video` | sim | `video/mp4` |
| `video-audio` | `videoWithAudio` | não | `video/mp4` |
| `narration` | `narrationAudio` | não | `audio/mpeg` |
| `captions` | `captions` | não | `application/x-subrip` |
| `cover` | `cover` | sim | `image/png` / `image/jpeg` |
- Leitura de lista/detalhe para o C194: `reel` filtrado por `status: published` (a biblioteca) e o detalhe com os `kind`s disponíveis; nenhuma projeção nova no C193 além do que o access/admin já expõe.
- Sem agregação, sem métrica, sem gráfico. Nada a apresentar.
## Fases verificáveis
1. **Puro + RBAC (unit).** Criar `src/lib/reel.ts` (constantes, labels, mapa `kind → campo`, content types) e renomear `canReadSpeechCatalog` → `canReadCommunicationCatalog` com todas as referências e o barrel. Unit: boundary das listas, mapa `kind → campo`, parser puro de headers (`Range`/`download`/`Cache-Control`), comportamento do predicado renomeado.
2. **Collections + access + storage.** Criar `Reel`/`ReelMedia`, `src/utilities/access/reels.ts` + reexport, registrar em `payload.config.ts` e no `s3Storage.collections`. Editar `campaignAccess.ts`. `pnpm generate:types`.
3. **Migration (HARD-STOP de aprovação humana).** `pnpm migrate:create add_reel` → revisar SQL (tabelas, enums, FKs, rels, índices) → aprovação registrada → `pnpm migrate` local. Nunca editar migrações antigas.
4. **Serving privado.** Criar `src/utilities/reels/reelMediaResponse.ts` e a rota `.../reels/[id]/media/[kind]/route.ts`. Unit do parser e do mapa; int de integração da rota (matriz de papéis + kill switch).
5. **Testes int + e2e (decisão).** `tests/int/reel.int.spec.ts` e `tests/int/reelMedia.int.spec.ts` seguindo `tests/int/speechCut.int.spec.ts` (mock de `next/cache` conforme :22-26). Registrar que e2e de fluxo fica no C194 (sem UI); a mudança em `src/utilities/access` e em `src/lib/campaignRoles` já acorda os specs curados de RBAC/comunicação pelo manifest (`scripts/lib/e2e-affected-manifest.mjs:254,310-313`).
6. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Hard-stops (aprovação humana explícita)
1. **Migração de schema** (Fase 3): aprovada na sessão `--auto` (2026-09-18) para criar/aplicar `add_reel` **só no banco local** do worktree; nunca editar migrações antigas, nunca tocar banco remoto/prod.
2. **`unpublished` invalida o acesso ao arquivo** (recomendação B do C193): **decidido B** (2026-09-18) — `draft`/`unpublished` → 404 no serving. Se o C194 quiser link direto para despublicado, reabrir produto.
3. **Feature como lista fechada** (`cards`) e **aprovação por qualquer um dos três papéis**: **confirmadas** as recomendações A (2026-09-18).
## Rabbit holes / Não escopo (engenharia)
- Transcode, compressão, extração de thumb/frame, normalização de áudio, mux de vídeo+áudio.
- Versionamento/revisão de artefatos: um artefato por formato; trocar = substituir.
- STT/geração de `.srt`/legenda automática ou clone de voz.
- Galeria pública, embed no site, publicação/agendamento no Instagram, métricas sociais.
- CRUD de catálogo de funcionalidades (o vocabulário começa fixo).
- UI de biblioteca, player 9:16, painel de downloads e rotas autenticadas de página — C194.
- Upload/ingestão automática de pacote e watcher do homeserver — C195.
- Renomear `Media` ou mudar seu access; rota pública nova; `Consent` novo.
## Riscos e mitigação
- **Vazamento de arquivo privado por `media`/rota pública.** Mitigação: `reelMedia` separada, sem superfície pública; `Media.read` intocado e pinado no lockdown; rota sempre gated + Local API `overrideAccess: false`.
- **S3 ausente/parcial.** Mitigação: `resolveS3StorageEnv` fail-closed; dev/test caem em disco local. Risco residual: esquecer `reelMedia: true` no plugin — coberto no aceite e por inspeção de config.
- **Cookie não chega à rota.** Mitigação: rota sob `/campanha` (path do cookie) e gate por `getCampaignUser()`; sem depender de header em `<video>`.
- **Streaming incorreto (Range/206/416/Content-Length).** Mitigação: reusar `parseRangeHeader`/`getRangeRequestInfo` do `payload/internal` (mesma semântica do plugin) e isolar o cálculo puro em unit com `Range` inválido/aberto/fechado.
- **Kill switch com cache.** Mitigação: `Cache-Control: private, no-store` em toda resposta; sem `unstable_cache` do reel. Se o C194 introduzir cache, o hook de revalidação volta ao dono.
- **Rename do predicado tocar superfície ampla.** Mitigação: rename mecânico com unit do predicado e o manifest acordando `campaignSpeechAcervo`/`campaignSpeechCut`.
- **e2e não cobre o fluxo novo.** Mitigação: decisão registrada (C194); a superfície de risco (`src/utilities/access`, `src/lib/campaignRoles`, `${CAMPAIGN_APP}/comunicacao`) continua mapeada e roda o conjunto curado.
## Já resolvido no simplify/critique (não reabrir)
- `REEL_MEDIA_INLINE_MIME_TYPES` exportado sem consumidor (quebrava `pnpm knip`) → não exportado.
- `mimeType as string` redundante em `reelMediaContentType` → `isInlineSafe` virou type predicate.
- Spread `...(required ? { required: true } : {})` no campo de upload → `required` direto.
- Literal `'reelMedia'` duplicado (slug/staticDir/rota) → `REEL_MEDIA_SLUG` em `src/lib/reel.ts`.
- 404 da rota e do utilitário com `Cache-Control` divergentes → `REEL_MEDIA_CACHE_CONTROL` único.
- `isMissingObject` checava `httpStatusCode` (inexistente no AWS SDK v3) → `$metadata.httpStatusCode`.
- Objeto S3 de 0 byte virava 500 enquanto o disco servia 200 → alinhado (só `ContentLength` nulo falha).
- Comentário desatualizado em `nav.ts`; variável de fixture `narration` guardando `.srt`; drift de nomes no impl plan.
## Explicitamente fora (skips, descartes e defers deste triage)
- **Registrado:** ramo S3 sem teste → C198 (#1162), `docs/plans/reels-privado-hardening.md` (score 4, expensive_lock; `depends: [C193]`).
- **Defer (contrato `src/lib/reel.ts`):** `reelMediaPath` e `isReelStatus` sem call site de produção. Gatilho: primeiro consumidor no C194 (#1153)/C195 (#1154) — se sobrar sem uso no merge do C194, apagar.
- **Defer (mapa kind→campo):** `ReelMediaField` reescreve os nomes dos uploads em vez de derivar `keyof Reel` de `@/payload-types`. Gatilho: rename de campo do `Reel` ou o C194 passando a ler `reelMediaFieldByKind` — então tipar o mapa contra `keyof Reel`.
- **Descarte:** gate key `'speechCatalog'` em `campaignPageActor.ts` (rename de pureza; se o C194 tocar o gate, renomeia de carona).
- **Descarte:** corpos idênticos de `canReadReel`/`canDeleteReel` (duplicação deliberada, precedente `canDeleteSpeechCut` — nunca alias de leitura).
- **Descarte:** fallback defensivo de `staticDir` na rota (guard exigido pelo tipo do Payload; fallback sem custo).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: registro com título/feature/status; artefatos anexáveis com vídeo sem áudio primário; nenhum arquivo abre sem login; aprovar não dispara ação externa; `unpublished` some da biblioteca e deixa de ser servido; só papéis da comunicação; site público e cards idênticos.
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user` em toda leitura; sem `Consent` novo; `Media.read` intocado; identificadores em inglês, copy/labels em pt-BR; "edit the owner, don't twin" (rename do predicado, sem twin); módulo novo em subpasta de domínio (`src/utilities/reels/`), sem top-level novo; migração nova sem editar antigas; `pnpm push` como caminho de entrega.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: unit do predicado/mapa `kind → campo`/parser de Range; int de access (communicator/coordinator/candidate passam; advisor/leader e anônimo negados), kill switch (`draft`/`unpublished` → 404) e stamping de ator/transação.
## Self-score de decision-quality
**4.5/5.** As três decisões estruturais (collection privada separada, serving por rota autenticada streamando direto, rename do predicado) têm alternativas honestas registradas e rejeições com motivo ancorado em file:line (endpoint S3 interno, `Media.read` público, top-level pin). Perde 0,5 porque a decisão de produto sobre `unpublished` fica em conflito conhecido com o C194 e só se fecha no gate — o plano adota B e explicita o custo de reverter, mas a qualidade final depende dessa confirmação humana.

View file

@ -2,7 +2,7 @@ import config from '@payload-config'
import { after, NextResponse } from 'next/server' import { after, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles' import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { speechCoverUrl } from '@/lib/speechVod' import { speechCoverUrl } from '@/lib/speechVod'
import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignUser } from '@/utilities/campaignAuth'
import { ensureSpeechPoster, SPEECH_POSTER_WAIT_MS } from '@/utilities/speech/speechPosterJob' import { ensureSpeechPoster, SPEECH_POSTER_WAIT_MS } from '@/utilities/speech/speechPosterJob'
@ -55,7 +55,7 @@ export const GET = async (
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
): Promise<NextResponse> => { ): Promise<NextResponse> => {
const user = await getCampaignUser() const user = await getCampaignUser()
if (!user || !canReadSpeechCatalog(user.role)) return emptyResponse(404) if (!user || !canReadCommunicationCatalog(user.role)) return emptyResponse(404)
const { id } = await params const { id } = await params
const speechId = Number(id) const speechId = Number(id)

View file

@ -0,0 +1,66 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import {
canServeReelMedia,
isReelMediaKind,
REEL_MEDIA_SLUG,
reelMediaFieldByKind,
} from '@/lib/reel'
import { REEL_MEDIA_CACHE_CONTROL } from '@/lib/reelMedia'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { buildReelMediaResponse } from '@/utilities/reels/reelMediaResponse'
export const dynamic = 'force-dynamic'
/**
* C193 — the only door to a reel file. Lives under `/campanha` because the
* `campaign-token` cookie is scoped to that path (a `<video>`/`<img>` sends the
* cookie, never an Authorization header). The gate is the same communication
* catalog predicate as the collection access; only a `published` reel serves
* its files (the kill switch), and every denial is a silent `404` so the route
* never leaks which reels exist.
*/
const notFound = (): NextResponse =>
new NextResponse(null, { status: 404, headers: { 'Cache-Control': REEL_MEDIA_CACHE_CONTROL } })
export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string; kind: string }> },
): Promise<Response> => {
const user = await getCampaignUser()
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
const { id, kind } = await params
const reelId = Number(id)
if (!Number.isInteger(reelId) || reelId <= 0 || !isReelMediaKind(kind)) return notFound()
const payload = await getPayload({ config })
const reel = await payload
.findByID({
collection: 'reel',
id: reelId,
depth: 1,
user,
overrideAccess: false,
})
.catch(() => null)
if (!reel || !canServeReelMedia(reel.status)) return notFound()
const media = reel[reelMediaFieldByKind[kind]]
if (!media || typeof media !== 'object') return notFound()
const upload = payload.collections[REEL_MEDIA_SLUG].config.upload
const staticDir =
upload && typeof upload === 'object' && upload.staticDir ? upload.staticDir : REEL_MEDIA_SLUG
return buildReelMediaResponse({
media,
staticDir,
rangeHeader: request.headers.get('range'),
download: new URL(request.url).searchParams.get('download') === '1',
})
}

View file

@ -2,7 +2,7 @@
import type { Payload } from 'payload' import type { Payload } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles' import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { import {
SPEECH_CUT_FORBIDDEN_MESSAGE, SPEECH_CUT_FORBIDDEN_MESSAGE,
SPEECH_CUT_INVALID_RANGE_MESSAGE, SPEECH_CUT_INVALID_RANGE_MESSAGE,
@ -56,7 +56,7 @@ export const resolveSpeechVodForActor = async (input: {
const { speechId } = speechVodRequestSchema.parse(input) const { speechId } = speechVodRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_VOD_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_VOD_FORBIDDEN_MESSAGE)
const result = await payload.find({ const result = await payload.find({
collection: 'speech', collection: 'speech',
@ -227,7 +227,7 @@ export const saveSpeechCutForActor = async (
const parsed = speechCutRequestSchema.parse(input) const parsed = speechCutRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
return 'retryOf' in parsed return 'retryOf' in parsed
? retrySpeechCut(payload, actor, parsed.retryOf) ? retrySpeechCut(payload, actor, parsed.retryOf)
@ -241,7 +241,7 @@ export const getSpeechCutStatusForActor = async (input: {
const { cutId } = speechCutStatusRequestSchema.parse(input) const { cutId } = speechCutStatusRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
let cut = await loadSpeechCutForActor(payload, actor, cutId) let cut = await loadSpeechCutForActor(payload, actor, cutId)
if (!cut) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE) if (!cut) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -273,7 +273,7 @@ export const updateSpeechCutTextForActor = async (
const parsed = speechCutTextUpdateRequestSchema.parse(input) const parsed = speechCutTextUpdateRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId) const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE) if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -300,7 +300,7 @@ export const setSpeechCutPublishedForActor = async (
const parsed = speechCutPublicationRequestSchema.parse(input) const parsed = speechCutPublicationRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId) const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE) if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -333,7 +333,7 @@ export const deleteSpeechCutForActor = async (
const parsed = speechCutCutIdRequestSchema.parse(input) const parsed = speechCutCutIdRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const current = await loadSpeechCutForActor(payload, actor, parsed.cutId) const current = await loadSpeechCutForActor(payload, actor, parsed.cutId)
if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE) if (!current) throw new Error(SPEECH_CUT_NOT_FOUND_MESSAGE)
@ -358,7 +358,7 @@ export const retrySpeechCutForActor = async (
const parsed = speechCutCutIdRequestSchema.parse(input) const parsed = speechCutCutIdRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
return retrySpeechCut(payload, actor, parsed.cutId) return retrySpeechCut(payload, actor, parsed.cutId)
} }
@ -372,7 +372,7 @@ export const suggestSpeechCutMetadataForActor = async (input: {
const parsed = speechCutSuggestionRequestSchema.parse(input) const parsed = speechCutSuggestionRequestSchema.parse(input)
const { payload, actor } = await getCampaignActionContext() const { payload, actor } = await getCampaignActionContext()
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE) if (!canReadCommunicationCatalog(actor.role)) throw new Error(SPEECH_CUT_FORBIDDEN_MESSAGE)
const speech = await loadSpeechForCut(payload, actor, parsed.speechId) const speech = await loadSpeechForCut(payload, actor, parsed.speechId)
if (!speech) throw new Error(SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE) if (!speech) throw new Error(SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE)

157
src/collections/Reel.ts Normal file
View file

@ -0,0 +1,157 @@
import type { CollectionBeforeChangeHook, CollectionConfig, Field } from 'payload'
import {
REEL_FEATURES,
REEL_MEDIA_SLUG,
REEL_STATUSES,
REEL_TITLE_MAX_LENGTH,
reelFeatureLabels,
reelStatusLabels,
} from '@/lib/reel'
import { canDeleteReel, canReadReel, canSetCampaignSystemField } from '@/utilities/campaignAccess'
import { stampCampaignCreatedBy, systemStampedActorField } from '@/utilities/campaignAuditFields'
/**
* C193 — one reel of tutorial of the site's functionalities, produced outside
* the Teqo and registered here with its artifacts. Everything is private: the
* files live in `reelMedia` and are served only through the authenticated
* `/campanha/.../media/[kind]` route. `status` is the kill switch (`published`
* → `unpublished`); approving never triggers an external action. The C194
* library lists the published rows and the C195 ingest writes through the Local
* API.
*/
const STATUS_OPTIONS = REEL_STATUSES.map((value) => ({ value, label: reelStatusLabels[value] }))
const FEATURE_OPTIONS = REEL_FEATURES.map((value) => ({ value, label: reelFeatureLabels[value] }))
const reelMediaField = ({
name,
label,
description,
required = false,
}: {
name: string
label: string
description: string
required?: boolean
}): Field => ({
name,
type: 'upload',
relationTo: REEL_MEDIA_SLUG,
label,
required: required ?? false,
admin: { description },
})
/**
* Stamps `publishedAt` on every transition into `published` (create included),
* so the C194 list can order by publication without each caller remembering it.
* Leaving `published` never clears the stamp — it records the last publication.
*/
const stampReelPublishedAt: CollectionBeforeChangeHook = ({ data, operation, originalDoc }) => {
if (
data.status === 'published' &&
(operation === 'create' || originalDoc?.status !== 'published')
) {
data.publishedAt = new Date().toISOString()
}
return data
}
export const Reel: CollectionConfig = {
slug: 'reel',
labels: {
singular: 'Reel',
plural: 'Reels',
},
admin: {
group: 'Comunicação',
useAsTitle: 'title',
defaultColumns: ['title', 'feature', 'status', 'publishedAt'],
description:
'Reels de tutorial da assessoria. Os arquivos são privados e só abrem com login da campanha; publicar não dispara nada no Instagram.',
},
access: {
create: canReadReel,
read: canReadReel,
update: canReadReel,
delete: canDeleteReel,
},
hooks: {
beforeChange: [stampReelPublishedAt, stampCampaignCreatedBy],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
maxLength: REEL_TITLE_MAX_LENGTH,
},
{
name: 'feature',
type: 'select',
label: 'Funcionalidade-alvo',
required: true,
options: FEATURE_OPTIONS,
admin: {
description: 'Funcionalidade do site que o tutorial ensina.',
},
},
{
name: 'status',
type: 'select',
label: 'Estado',
required: true,
defaultValue: 'draft',
index: true,
options: STATUS_OPTIONS,
admin: {
description:
'Despublicar tira o reel da biblioteca e para de servir os arquivos (kill switch).',
},
},
reelMediaField({
name: 'video',
label: 'Vídeo (sem áudio, primário)',
required: true,
description: 'MP4 1080×1920 sem áudio — o arquivo principal que a assessoria baixa.',
}),
reelMediaField({
name: 'videoWithAudio',
label: 'Vídeo com áudio (rascunho)',
description: 'Variante com a narração embutida, quando existir.',
}),
reelMediaField({
name: 'narrationAudio',
label: 'Narração (MP3)',
description: 'Rascunho da narração em áudio, quando existir.',
}),
reelMediaField({
name: 'captions',
label: 'Legendas (.srt)',
description: 'Arquivo de legenda pronto, vindo da produção.',
}),
reelMediaField({
name: 'cover',
label: 'Capa (1080×1920)',
required: true,
description: 'Imagem vertical usada na biblioteca e no player.',
}),
{
name: 'transcript',
type: 'textarea',
label: 'Transcrição / roteiro',
admin: {
description: 'Texto da narração ou roteiro, vindo da produção.',
},
},
{
name: 'publishedAt',
type: 'date',
label: 'Publicado em',
admin: { readOnly: true },
},
systemStampedActorField({ setAccess: canSetCampaignSystemField }),
],
}

View file

@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
import { REEL_MEDIA_SLUG } from '@/lib/reel'
import { canDeleteReel, canReadReel } from '@/utilities/campaignAccess'
/**
* C193 — private upload collection of a reel's artifacts. Deliberately NOT
* `media`: that collection is anonymous-read by contract (public pages), while
* a reel file may only be read by the communication roles. The browser reaches
* these files through the authenticated
* `/campanha/comunicacao/reels/[id]/media/[kind]` route, never the public
* `/api/media/file` proxy.
*/
export const ReelMedia: CollectionConfig = {
slug: REEL_MEDIA_SLUG,
labels: {
singular: 'Mídia de reel',
plural: 'Mídias de reel',
},
admin: {
group: 'Comunicação',
description:
'Arquivos privados dos reels (vídeo, narração, legenda, capa). Só abrem com login da campanha.',
},
access: {
create: canReadReel,
read: canReadReel,
update: canReadReel,
delete: canDeleteReel,
},
fields: [
{
name: 'alt',
type: 'text',
label: 'Texto alternativo',
required: true,
admin: {
description: 'Descrição do artefato para acessibilidade (capa e vídeo).',
},
},
],
upload: true,
}

View file

@ -29,7 +29,7 @@ import {
LEADER_CONTACTS_HOME, LEADER_CONTACTS_HOME,
} from '@/lib/campaignPaths' } from '@/lib/campaignPaths'
import { import {
canReadSpeechCatalog, canReadCommunicationCatalog,
isStaffCampaignRole, isStaffCampaignRole,
isUnrestrictedCampaignRole, isUnrestrictedCampaignRole,
} from '@/lib/campaignRoles' } from '@/lib/campaignRoles'
@ -138,8 +138,8 @@ export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] =>
return staffNav.filter((item) => { return staffNav.filter((item) => {
if (item.href === '/campanha/apoiadores') return canAccessSupporterArea(role) if (item.href === '/campanha/apoiadores') return canAccessSupporterArea(role)
if (item.href === '/campanha/assessores') return isUnrestrictedCampaignRole(role) if (item.href === '/campanha/assessores') return isUnrestrictedCampaignRole(role)
// C154 — advisor has no speech catalog access; coordinator/candidate do. // C154 — advisor has no communication vertical access; coordinator/candidate do.
if (item.href === CAMPAIGN_COMMUNICATION_HOME) return canReadSpeechCatalog(role) if (item.href === CAMPAIGN_COMMUNICATION_HOME) return canReadCommunicationCatalog(role)
return true return true
}) })
} }

View file

@ -18,9 +18,11 @@ export const isUnrestrictedCampaignRole = (role: CampaignRole): boolean =>
role === 'coordinator' || role === 'candidate' role === 'coordinator' || role === 'candidate'
/** /**
* Speech catalog readers (C153): the communication assessor plus the * Communication vertical readers (C153/C193): the communication assessor plus
* unrestricted roles. Deliberately NOT part of `isStaffCampaignRole` — the * the unrestricted roles. Owns the gate of the whole vertical — acervo de
* communicator does not inherit any staff area, only the catalog vertical. * falas, biblioteca de cortes and the private reel library. Deliberately NOT
* part of `isStaffCampaignRole` — the communicator does not inherit any staff
* area, only the communication vertical.
*/ */
export const canReadSpeechCatalog = (role: CampaignRole): boolean => export const canReadCommunicationCatalog = (role: CampaignRole): boolean =>
role === 'communicator' || isUnrestrictedCampaignRole(role) role === 'communicator' || isUnrestrictedCampaignRole(role)

66
src/lib/reel.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* C193 — pure rules of a reel: the status vocabulary (kill switch), the
* feature catalog, the artifact `kind` → upload-field map and the private
* media path. No I/O and no `server-only`: the collection config, the
* authenticated media route and the C194 library share this module.
*/
/** Upload collection of the artifacts; also its local disk directory in dev. */
export const REEL_MEDIA_SLUG = 'reelMedia'
export const REEL_STATUSES = ['draft', 'published', 'unpublished'] as const
export type ReelStatus = (typeof REEL_STATUSES)[number]
/** One owner for the persisted field limit (collection, schema and C194). */
export const REEL_TITLE_MAX_LENGTH = 200
export const reelStatusLabels: Record<ReelStatus, string> = {
draft: 'Rascunho',
published: 'Publicado',
unpublished: 'Despublicado',
}
export const isReelStatus = (value: unknown): value is ReelStatus =>
REEL_STATUSES.includes(value as ReelStatus)
/**
* The closed feature vocabulary. Starts with `cards` (the home support cards at
* `#cards`); a new tutorial feature appends here — never free text.
*/
export const REEL_FEATURES = ['cards'] as const
export type ReelFeature = (typeof REEL_FEATURES)[number]
export const reelFeatureLabels: Record<ReelFeature, string> = {
cards: 'Cards de apoio (#cards)',
}
/**
* The artifacts a reel can carry, each mapping to exactly one upload field.
* `video` is the primary file (silent); the others are optional variants.
*/
export const REEL_MEDIA_KINDS = ['video', 'video-audio', 'narration', 'captions', 'cover'] as const
export type ReelMediaKind = (typeof REEL_MEDIA_KINDS)[number]
export type ReelMediaField = 'video' | 'videoWithAudio' | 'narrationAudio' | 'captions' | 'cover'
export const reelMediaFieldByKind: Record<ReelMediaKind, ReelMediaField> = {
video: 'video',
'video-audio': 'videoWithAudio',
narration: 'narrationAudio',
captions: 'captions',
cover: 'cover',
}
export const isReelMediaKind = (value: unknown): value is ReelMediaKind =>
REEL_MEDIA_KINDS.includes(value as ReelMediaKind)
/**
* The authenticated serving path of one artifact. Lives under `/campanha` on
* purpose: the `campaign-token` cookie is scoped to that path, so `<video>`/
* `<img>` reach it without an `Authorization` header.
*/
export const reelMediaPath = (reelId: number, kind: ReelMediaKind): string =>
`/campanha/comunicacao/reels/${reelId}/media/${kind}`
/** Only a published reel serves its files; `draft`/`unpublished` answer 404. */
export const canServeReelMedia = (status: ReelStatus): boolean => status === 'published'

66
src/lib/reelMedia.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* C193 — pure response rules of a private reel artifact: the safe content type,
* the content disposition and the range/private headers. No I/O and no
* `server-only`: the streaming utility and the unit tests share this module.
*/
/** Only these may render inline; anything else is forced to download. */
const REEL_MEDIA_INLINE_MIME_TYPES = ['video/mp4', 'audio/mpeg', 'image/png', 'image/jpeg'] as const
export const REEL_MEDIA_FALLBACK_MIME_TYPE = 'application/octet-stream'
export const REEL_MEDIA_CACHE_CONTROL = 'private, no-store'
export type ReelMediaRange = {
status: number
headers: Record<string, string>
}
const isInlineSafe = (mimeType: unknown): mimeType is string =>
typeof mimeType === 'string' &&
(REEL_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType)
/**
* The served content type is the stored one only when it is safe to render
* inline; everything else degrades to `application/octet-stream` (never an
* attacker-chosen type executed in the campaign origin).
*/
export const reelMediaContentType = (mimeType: string | null | undefined): string =>
isInlineSafe(mimeType) ? mimeType : REEL_MEDIA_FALLBACK_MIME_TYPE
/** Both `filename` and `filename*` (RFC 5987) so non-ASCII names survive. */
export const reelMediaContentDisposition = (
filename: string | null | undefined,
download: boolean,
): string => {
const name = filename?.trim() || 'arquivo'
const asciiFallback = name.replace(/[^\w.-]+/g, '_')
const disposition = download ? 'attachment' : 'inline'
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
}
/**
* The response headers of one artifact: the range headers computed from the
* real file size plus the private streaming contract. Pure.
*/
export const reelMediaHeaders = ({
range,
mimeType,
filename,
download,
}: {
range: ReelMediaRange
mimeType: string | null | undefined
filename: string | null | undefined
download: boolean
}): Headers => {
const headers = new Headers(range.headers)
const contentType = reelMediaContentType(mimeType)
const forceDownload = download || contentType === REEL_MEDIA_FALLBACK_MIME_TYPE
headers.set('Content-Type', contentType)
headers.set('Content-Disposition', reelMediaContentDisposition(filename, forceDownload))
headers.set('Cache-Control', REEL_MEDIA_CACHE_CONTROL)
headers.set('X-Content-Type-Options', 'nosniff')
if (forceDownload) headers.set('Content-Security-Policy', "default-src 'none'")
return headers
}

View file

@ -13,7 +13,7 @@ export const speechVodRequestSchema = z.object({
* end only would silently collapse a real refusal into the generic error. * end only would silently collapse a real refusal into the generic error.
*/ */
/** The catalog gate (`canReadSpeechCatalog`) refused the actor. */ /** The catalog gate (`canReadCommunicationCatalog`) refused the actor. */
export const SPEECH_VOD_FORBIDDEN_MESSAGE = 'Você não tem acesso ao acervo de falas.' export const SPEECH_VOD_FORBIDDEN_MESSAGE = 'Você não tem acesso ao acervo de falas.'
/** The speech does not exist (or the actor cannot read it). */ /** The speech does not exist (or the actor cannot read it). */

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_reel_feature" AS ENUM('cards');
CREATE TYPE "public"."enum_reel_status" AS ENUM('draft', 'published', 'unpublished');
CREATE TABLE "reel" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"feature" "enum_reel_feature" NOT NULL,
"status" "enum_reel_status" DEFAULT 'draft' NOT NULL,
"video_id" integer NOT NULL,
"video_with_audio_id" integer,
"narration_audio_id" integer,
"captions_id" integer,
"cover_id" integer NOT NULL,
"transcript" varchar,
"published_at" timestamp(3) with time zone,
"created_by_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "reel_media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "reel_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "reel_media_id" integer;
ALTER TABLE "reel" ADD CONSTRAINT "reel_video_id_reel_media_id_fk" FOREIGN KEY ("video_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_video_with_audio_id_reel_media_id_fk" FOREIGN KEY ("video_with_audio_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_narration_audio_id_reel_media_id_fk" FOREIGN KEY ("narration_audio_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_captions_id_reel_media_id_fk" FOREIGN KEY ("captions_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_cover_id_reel_media_id_fk" FOREIGN KEY ("cover_id") REFERENCES "public"."reel_media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "reel" ADD CONSTRAINT "reel_created_by_id_campaign_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "reel_status_idx" ON "reel" USING btree ("status");
CREATE INDEX "reel_video_idx" ON "reel" USING btree ("video_id");
CREATE INDEX "reel_video_with_audio_idx" ON "reel" USING btree ("video_with_audio_id");
CREATE INDEX "reel_narration_audio_idx" ON "reel" USING btree ("narration_audio_id");
CREATE INDEX "reel_captions_idx" ON "reel" USING btree ("captions_id");
CREATE INDEX "reel_cover_idx" ON "reel" USING btree ("cover_id");
CREATE INDEX "reel_created_by_idx" ON "reel" USING btree ("created_by_id");
CREATE INDEX "reel_updated_at_idx" ON "reel" USING btree ("updated_at");
CREATE INDEX "reel_created_at_idx" ON "reel" USING btree ("created_at");
CREATE INDEX "reel_media_updated_at_idx" ON "reel_media" USING btree ("updated_at");
CREATE INDEX "reel_media_created_at_idx" ON "reel_media" USING btree ("created_at");
CREATE UNIQUE INDEX "reel_media_filename_idx" ON "reel_media" USING btree ("filename");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_reel_fk" FOREIGN KEY ("reel_id") REFERENCES "public"."reel"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_reel_media_fk" FOREIGN KEY ("reel_media_id") REFERENCES "public"."reel_media"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_reel_id_idx" ON "payload_locked_documents_rels" USING btree ("reel_id");
CREATE INDEX "payload_locked_documents_rels_reel_media_id_idx" ON "payload_locked_documents_rels" USING btree ("reel_media_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "reel" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "reel_media" DISABLE ROW LEVEL SECURITY;
DROP TABLE "reel" CASCADE;
DROP TABLE "reel_media" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_reel_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_reel_media_fk";
DROP INDEX "payload_locked_documents_rels_reel_id_idx";
DROP INDEX "payload_locked_documents_rels_reel_media_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "reel_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "reel_media_id";
DROP TYPE "public"."enum_reel_feature";
DROP TYPE "public"."enum_reel_status";`)
}

View file

@ -72,6 +72,7 @@ import * as migration_20260913_145820_add_speech_search_text from './20260913_14
import * as migration_20260913_150000_backfill_speech_search_text_trgm_index from './20260913_150000_backfill_speech_search_text_trgm_index'; import * as migration_20260913_150000_backfill_speech_search_text_trgm_index from './20260913_150000_backfill_speech_search_text_trgm_index';
import * as migration_20260915_122356_add_speech_cut from './20260915_122356_add_speech_cut'; import * as migration_20260915_122356_add_speech_cut from './20260915_122356_add_speech_cut';
import * as migration_20260915_142058_activity_google_import_link from './20260915_142058_activity_google_import_link'; import * as migration_20260915_142058_activity_google_import_link from './20260915_142058_activity_google_import_link';
import * as migration_20260918_201009_add_reel from './20260918_201009_add_reel';
export const migrations = [ export const migrations = [
{ {
@ -442,6 +443,11 @@ export const migrations = [
{ {
up: migration_20260915_142058_activity_google_import_link.up, up: migration_20260915_142058_activity_google_import_link.up,
down: migration_20260915_142058_activity_google_import_link.down, down: migration_20260915_142058_activity_google_import_link.down,
name: '20260915_142058_activity_google_import_link' name: '20260915_142058_activity_google_import_link',
},
{
up: migration_20260918_201009_add_reel.up,
down: migration_20260918_201009_add_reel.down,
name: '20260918_201009_add_reel'
}, },
]; ];

View file

@ -89,6 +89,8 @@ export interface Config {
speech: Speech; speech: Speech;
speechSegment: SpeechSegment; speechSegment: SpeechSegment;
speechCut: SpeechCut; speechCut: SpeechCut;
reel: Reel;
reelMedia: ReelMedia;
calendarFeed: CalendarFeed; calendarFeed: CalendarFeed;
googleCalendarSync: GoogleCalendarSync; googleCalendarSync: GoogleCalendarSync;
electionTally: ElectionTally; electionTally: ElectionTally;
@ -133,6 +135,8 @@ export interface Config {
speech: SpeechSelect<false> | SpeechSelect<true>; speech: SpeechSelect<false> | SpeechSelect<true>;
speechSegment: SpeechSegmentSelect<false> | SpeechSegmentSelect<true>; speechSegment: SpeechSegmentSelect<false> | SpeechSegmentSelect<true>;
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>; speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
reel: ReelSelect<false> | ReelSelect<true>;
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>; calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>; googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>; electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
@ -1061,6 +1065,76 @@ export interface SpeechCut {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* Reels de tutorial da assessoria. Os arquivos são privados e só abrem com login da campanha; publicar não dispara nada no Instagram.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reel".
*/
export interface Reel {
id: number;
title: string;
/**
* Funcionalidade do site que o tutorial ensina.
*/
feature: 'cards';
/**
* Despublicar tira o reel da biblioteca e para de servir os arquivos (kill switch).
*/
status: 'draft' | 'published' | 'unpublished';
/**
* MP4 1080×1920 sem áudio — o arquivo principal que a assessoria baixa.
*/
video: number | ReelMedia;
/**
* Variante com a narração embutida, quando existir.
*/
videoWithAudio?: (number | null) | ReelMedia;
/**
* Rascunho da narração em áudio, quando existir.
*/
narrationAudio?: (number | null) | ReelMedia;
/**
* Arquivo de legenda pronto, vindo da produção.
*/
captions?: (number | null) | ReelMedia;
/**
* Imagem vertical usada na biblioteca e no player.
*/
cover: number | ReelMedia;
/**
* Texto da narração ou roteiro, vindo da produção.
*/
transcript?: string | null;
publishedAt?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* Arquivos privados dos reels (vídeo, narração, legenda, capa). Só abrem com login da campanha.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reelMedia".
*/
export interface ReelMedia {
id: number;
/**
* Descrição do artefato para acessibilidade (capa e vídeo).
*/
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed". * via the `definition` "calendarFeed".
@ -1610,6 +1684,14 @@ export interface PayloadLockedDocument {
relationTo: 'speechCut'; relationTo: 'speechCut';
value: number | SpeechCut; value: number | SpeechCut;
} | null) } | null)
| ({
relationTo: 'reel';
value: number | Reel;
} | null)
| ({
relationTo: 'reelMedia';
value: number | ReelMedia;
} | null)
| ({ | ({
relationTo: 'calendarFeed'; relationTo: 'calendarFeed';
value: number | CalendarFeed; value: number | CalendarFeed;
@ -2191,6 +2273,43 @@ export interface SpeechCutSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reel_select".
*/
export interface ReelSelect<T extends boolean = true> {
title?: T;
feature?: T;
status?: T;
video?: T;
videoWithAudio?: T;
narrationAudio?: T;
captions?: T;
cover?: T;
transcript?: T;
publishedAt?: T;
createdBy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reelMedia_select".
*/
export interface ReelMediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "calendarFeed_select". * via the `definition` "calendarFeed_select".
@ -2924,6 +3043,8 @@ export interface TaskCreateCollectionExport {
| 'speech' | 'speech'
| 'speechSegment' | 'speechSegment'
| 'speechCut' | 'speechCut'
| 'reel'
| 'reelMedia'
| 'calendarFeed' | 'calendarFeed'
| 'googleCalendarSync' | 'googleCalendarSync'
| 'electionTally' | 'electionTally'

View file

@ -32,6 +32,8 @@ import { Organization } from './collections/Organization'
import { Petition } from './collections/Petition' import { Petition } from './collections/Petition'
import { Post } from './collections/Post' import { Post } from './collections/Post'
import { PushSubscription } from './collections/PushSubscription' import { PushSubscription } from './collections/PushSubscription'
import { Reel } from './collections/Reel'
import { ReelMedia } from './collections/ReelMedia'
import { Signature } from './collections/Signature' import { Signature } from './collections/Signature'
import { Speech } from './collections/Speech' import { Speech } from './collections/Speech'
import { SpeechCut } from './collections/SpeechCut' import { SpeechCut } from './collections/SpeechCut'
@ -117,6 +119,8 @@ export default buildConfig({
Speech, Speech,
SpeechSegment, SpeechSegment,
SpeechCut, SpeechCut,
Reel,
ReelMedia,
CalendarFeed, CalendarFeed,
GoogleCalendarSync, GoogleCalendarSync,
ElectionTally, ElectionTally,
@ -162,6 +166,9 @@ export default buildConfig({
s3Storage({ s3Storage({
collections: { collections: {
media: true, media: true,
// C193 — private reel artifacts; without this entry they would
// fall back to the container's ephemeral disk in production.
reelMedia: true,
}, },
bucket: mediaStorage.bucket, bucket: mediaStorage.bucket,
config: { config: {

View file

@ -0,0 +1,34 @@
// ---------------------------------------------------------------------------
// C193 — the private reel library (reel + reelMedia)
// ---------------------------------------------------------------------------
import type { Access } from 'payload'
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { getFreshCampaignUser, isPayloadAdmin } from '@/utilities/access/shared'
/**
* Readers/managers of the private reel library: Payload admin or campaign
* users with the `communicator`, `coordinator` or `candidate` role. Advisors
* and leaders are denied — the library is not municipality-scoped and not a
* staff area.
*/
export const canReadReel: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}
/**
* Who may remove a reel (or one of its files): the same library audience plus
* the Payload admin. Written as its own predicate, never an alias of
* `canReadReel`, so a future widening of the read surface cannot silently
* grant delete.
*/
export const canDeleteReel: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
}

View file

@ -4,7 +4,7 @@
import type { Access } from 'payload' import type { Access } from 'payload'
import { canReadSpeechCatalog } from '@/lib/campaignRoles' import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { import {
getFreshCampaignUser, getFreshCampaignUser,
isCampaignUnrestricted, isCampaignUnrestricted,
@ -20,7 +20,7 @@ export const canReadSpeech: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req) const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadSpeechCatalog(currentUser.role) : false return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
} }
/** Manual facet correction — unrestricted roles (and Payload admin) only. */ /** Manual facet correction — unrestricted roles (and Payload admin) only. */
@ -40,7 +40,7 @@ export const canDeleteSpeechCut: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req) const currentUser = await getFreshCampaignUser(req)
return currentUser ? canReadSpeechCatalog(currentUser.role) : false return currentUser ? canReadCommunicationCatalog(currentUser.role) : false
} }
/** /**
@ -54,7 +54,7 @@ export const canReadSpeechCut: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req) const currentUser = await getFreshCampaignUser(req)
return currentUser && canReadSpeechCatalog(currentUser.role) return currentUser && canReadCommunicationCatalog(currentUser.role)
? true ? true
: { status: { equals: 'published' } } : { status: { equals: 'published' } }
} }

View file

@ -2,7 +2,7 @@ import { tool } from 'ai'
import { z } from 'zod' import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types' import type { AIToolContext } from '@/lib/ai/types'
import { canReadSpeechCatalog } from '@/lib/campaignRoles' import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
import { formatSpeechClock } from '@/lib/speechClock' import { formatSpeechClock } from '@/lib/speechClock'
import { import {
buildSpeechExcerpt, buildSpeechExcerpt,
@ -87,7 +87,7 @@ export const findSpeechExcerpts = (ctx: AIToolContext) =>
.describe('Máximo de trechos sugeridos (default 3, max 5).'), .describe('Máximo de trechos sugeridos (default 3, max 5).'),
}), }),
execute: async ({ tema, intencao, limit }) => { execute: async ({ tema, intencao, limit }) => {
if (!canReadSpeechCatalog(ctx.user.role)) return { error: DENIED_MESSAGE } if (!canReadCommunicationCatalog(ctx.user.role)) return { error: DENIED_MESSAGE }
const terms = speechExcerptTerms(tema) const terms = speechExcerptTerms(tema)
const consulta = { tema: tema.trim(), termos: terms, intencao: intencao?.trim() ?? null } const consulta = { tema: tema.trim(), termos: terms, intencao: intencao?.trim() ?? null }

View file

@ -175,3 +175,5 @@ export {
canReadSpeechCut, canReadSpeechCut,
canUpdateSpeech, canUpdateSpeech,
} from '@/utilities/access/speeches' } from '@/utilities/access/speeches'
export { canDeleteReel, canReadReel } from '@/utilities/access/reels'

View file

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'
import { CAMPAIGN_COMMUNICATION_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import { CAMPAIGN_COMMUNICATION_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { import {
canReadSpeechCatalog, canReadCommunicationCatalog,
isStaffCampaignRole, isStaffCampaignRole,
isUnrestrictedCampaignRole, isUnrestrictedCampaignRole,
} from '@/lib/campaignRoles' } from '@/lib/campaignRoles'
@ -71,7 +71,7 @@ export const requireCampaignPageActor = async (
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect) if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
// C154 — the speech catalog vertical: communicator/coordinator/candidate, // C154 — the speech catalog vertical: communicator/coordinator/candidate,
// the same predicate the collection access uses. // the same predicate the collection access uses.
if (gate === 'speechCatalog' && !canReadSpeechCatalog(user.role)) redirect(denyRedirect) if (gate === 'speechCatalog' && !canReadCommunicationCatalog(user.role)) redirect(denyRedirect)
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect) if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect)
if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect) if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect)
if (gate === 'writable' && !isStaffCampaignRole(user.role)) redirect(denyRedirect) if (gate === 'writable' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)

View file

@ -0,0 +1,173 @@
import 'server-only'
import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import { getRangeRequestInfo } from 'payload/internal'
import { REEL_MEDIA_SLUG } from '@/lib/reel'
import { REEL_MEDIA_CACHE_CONTROL, reelMediaHeaders, type ReelMediaRange } from '@/lib/reelMedia'
import type { ReelMedia } from '@/payload-types'
import { resolveS3StorageEnv } from '@/utilities/mediaStorage'
/**
* C193 — serves one private reel artifact as an HTTP response. The reel media
* never goes through the public `/api/media/file` proxy: the authenticated
* route under `/campanha` opens the object here and streams it back.
*
* In production the object lives in the private Garage bucket (same S3_* envs
* as `media`); with no S3_* set (dev/test) it is read from the upload
* collection's local disk directory, mirroring Payload's own file handler.
*/
const DEFAULT_STATIC_DIR = REEL_MEDIA_SLUG
type ReelMediaFile = Pick<ReelMedia, 'filename' | 'filesize' | 'mimeType'>
type ReelMediaStorage = { bucket: string; client: S3Client }
let cachedStorage: ReelMediaStorage | null | undefined
const reelMediaStorage = (): ReelMediaStorage | null => {
if (cachedStorage !== undefined) return cachedStorage
const config = resolveS3StorageEnv(process.env)
cachedStorage = config.enabled
? {
bucket: config.bucket,
client: new S3Client({
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
region: config.region,
endpoint: config.endpoint,
forcePathStyle: true,
}),
}
: null
return cachedStorage
}
const isMissingObject = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false
const name = 'name' in error ? String(error.name) : ''
const code = 'Code' in error ? String(error.Code) : ''
const metadata =
'$metadata' in error && error.$metadata && typeof error.$metadata === 'object'
? (error.$metadata as { httpStatusCode?: number })
: null
return (
name === 'NoSuchKey' ||
name === 'NotFound' ||
code === 'NoSuchKey' ||
metadata?.httpStatusCode === 404
)
}
type OpenedObject = { range: ReelMediaRange; body: BodyInit | null }
const openS3Object = async ({
storage,
filename,
rangeHeader,
}: {
storage: ReelMediaStorage
filename: string
rangeHeader: string | null
}): Promise<OpenedObject> => {
const head = await storage.client.send(
new HeadObjectCommand({ Bucket: storage.bucket, Key: filename }),
)
const fileSize = head.ContentLength
if (fileSize == null) throw new Error(`Objeto de reel sem tamanho: ${filename}`)
const range = getRangeRequestInfo({ fileSize, rangeHeader })
if (range.type === 'invalid') return { range, body: null }
const object = await storage.client.send(
new GetObjectCommand({
Bucket: storage.bucket,
Key: filename,
...(range.type === 'partial' ? { Range: `bytes=${range.rangeStart}-${range.rangeEnd}` } : {}),
}),
)
if (!object.Body) throw new Error(`Objeto de reel sem corpo: ${filename}`)
return { range, body: object.Body as unknown as BodyInit }
}
const openLocalObject = async ({
staticDir,
filename,
rangeHeader,
}: {
staticDir: string
filename: string
rangeHeader: string | null
}): Promise<OpenedObject> => {
const resolvedDir = path.resolve(staticDir || DEFAULT_STATIC_DIR)
const filePath = path.resolve(resolvedDir, filename)
if (!filePath.startsWith(resolvedDir + path.sep)) {
throw new Error('Nome de arquivo de reel inválido.')
}
const stats = await stat(filePath)
const range = getRangeRequestInfo({ fileSize: stats.size, rangeHeader })
if (range.type === 'invalid') return { range, body: null }
const body =
range.type === 'partial'
? (createReadStream(filePath, {
start: range.rangeStart,
end: range.rangeEnd,
}) as unknown as BodyInit)
: (createReadStream(filePath) as unknown as BodyInit)
return { range, body }
}
const notFound = (): Response =>
new Response(null, { status: 404, headers: { 'Cache-Control': REEL_MEDIA_CACHE_CONTROL } })
/**
* Streams one artifact. A missing object answers `404`; an unsatisfiable range
* answers `416` (both with the private headers) — never a leaked S3 error.
*/
export const buildReelMediaResponse = async ({
media,
staticDir,
rangeHeader,
download,
}: {
media: ReelMediaFile
staticDir: string
rangeHeader: string | null
download: boolean
}): Promise<Response> => {
const filename = media.filename
if (!filename) return notFound()
try {
const storage = reelMediaStorage()
const opened = storage
? await openS3Object({ storage, filename, rangeHeader })
: await openLocalObject({ staticDir, filename, rangeHeader })
return new Response(opened.body, {
status: opened.range.status,
headers: reelMediaHeaders({
range: opened.range,
mimeType: media.mimeType,
filename,
download,
}),
})
} catch (error) {
if (isMissingObject(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return notFound()
}
throw error
}
}

290
tests/int/reel.int.spec.ts Normal file
View file

@ -0,0 +1,290 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
unstable_cache: (fn: unknown) => fn,
}))
const { getCampaignUserMock } = vi.hoisted(() => ({ getCampaignUserMock: vi.fn() }))
vi.mock('@/utilities/campaignAuth', () => ({
getCampaignUser: getCampaignUserMock,
}))
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/media/[kind]/route'
import type { CampaignUser, Reel, ReelMedia } from '@/payload-types'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const MP4_BYTES = Buffer.from('fixture-mp4-bytes')
const SRT_BYTES = Buffer.from('1\n00:00:00,000 --> 00:00:01,000\nOlá\n')
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 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',
withCaptions = false,
title = 'Reel de tutorial',
}: {
status?: Reel['status']
withCaptions?: boolean
title?: string
} = {}): Promise<{ reel: Reel; video: ReelMedia }> => {
const video = await createMedia('reel.mp4', MP4_BYTES, 'video/mp4')
const cover = await createMedia('capa.png', PNG_BYTES, 'image/png')
const captions = withCaptions
? await createMedia('narracao.srt', SRT_BYTES, 'application/x-subrip')
: null
const reel = await payload.create({
collection: 'reel',
data: {
title,
feature: 'cards',
status,
video: video.id,
cover: cover.id,
...(captions ? { captions: captions.id } : {}),
},
overrideAccess: true,
})
createdReelIds.add(reel.id)
return { reel, video }
}
const callRoute = async ({
reelId,
kind,
user,
range,
download,
}: {
reelId: number
kind: string
user?: CampaignUser | null
range?: string
download?: boolean
}): Promise<Response> => {
getCampaignUserMock.mockResolvedValue(user ?? null)
const request = new Request(
`http://localhost/campanha/comunicacao/reels/${reelId}/media/${kind}${download ? '?download=1' : ''}`,
{ headers: range ? { range } : undefined },
)
return GET(request, { params: Promise.resolve({ id: String(reelId), kind }) })
}
const findReelsAs = (user: CampaignUser | undefined, id: number) =>
payload.find({
collection: 'reel',
where: { id: { equals: id } },
depth: 0,
limit: 1,
...(user ? { user } : {}),
overrideAccess: false,
})
describe('reel private library (C193)', () => {
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('reads the reel only with the communication roles', async () => {
const { reel } = await createReel({ status: 'published' })
await expect(findReelsAs(undefined, reel.id)).rejects.toThrow()
await expect(findReelsAs(advisor, reel.id)).rejects.toThrow()
await expect(findReelsAs(leader, reel.id)).rejects.toThrow()
expect((await findReelsAs(communicator, reel.id)).docs).toHaveLength(1)
expect((await findReelsAs(coordinator, reel.id)).docs).toHaveLength(1)
expect((await findReelsAs(candidate, reel.id)).docs).toHaveLength(1)
})
it('stamps the actor and the publication through the create path', async () => {
const video = await createMedia('stamp.mp4', MP4_BYTES, 'video/mp4')
const cover = await createMedia('stamp.png', PNG_BYTES, 'image/png')
const reel = await payload.create({
collection: 'reel',
data: {
title: 'Reel com carimbo',
feature: 'cards',
status: 'published',
video: video.id,
cover: cover.id,
},
depth: 0,
user: communicator,
overrideAccess: false,
})
createdReelIds.add(reel.id)
expect(reel.createdBy).toBe(communicator.id)
expect(reel.publishedAt).toBeTruthy()
})
it('serves a published artifact to a communication role', async () => {
const { reel } = await createReel({ status: 'published' })
const response = await callRoute({ reelId: reel.id, kind: 'video', user: communicator })
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('video/mp4')
expect(response.headers.get('Content-Disposition')).toContain('inline')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(Buffer.from(await response.arrayBuffer())).toEqual(MP4_BYTES)
})
it('answers a byte range with 206 and the exact slice', async () => {
const { reel } = await createReel({ status: 'published' })
const response = await callRoute({
reelId: reel.id,
kind: 'video',
user: coordinator,
range: 'bytes=0-3',
})
expect(response.status).toBe(206)
expect(response.headers.get('Content-Range')).toBe(`bytes 0-3/${MP4_BYTES.length}`)
expect(response.headers.get('Content-Length')).toBe('4')
expect(Buffer.from(await response.arrayBuffer())).toEqual(MP4_BYTES.subarray(0, 4))
})
it('forces a download when asked and for unsafe types', async () => {
const { reel } = await createReel({ status: 'published', withCaptions: true })
const download = await callRoute({
reelId: reel.id,
kind: 'video',
user: communicator,
download: true,
})
expect(download.headers.get('Content-Disposition')).toContain('attachment')
const captions = await callRoute({
reelId: reel.id,
kind: 'captions',
user: communicator,
})
expect(captions.status).toBe(200)
expect(captions.headers.get('Content-Type')).toBe('application/octet-stream')
expect(captions.headers.get('Content-Disposition')).toContain('attachment')
expect(Buffer.from(await captions.arrayBuffer())).toEqual(SRT_BYTES)
})
it('kill switch: draft and unpublished never serve the file', async () => {
const draft = await createReel({ status: 'draft' })
const unpublished = await createReel({ status: 'unpublished' })
expect(
(await callRoute({ reelId: draft.reel.id, kind: 'video', user: communicator })).status,
).toBe(404)
expect(
(await callRoute({ reelId: unpublished.reel.id, kind: 'video', user: communicator })).status,
).toBe(404)
})
it('denies anonymous, advisor and leader with a silent 404', async () => {
const { reel } = await createReel({ status: 'published' })
for (const user of [undefined, advisor, leader]) {
const response = await callRoute({ reelId: reel.id, kind: 'video', user })
expect(response.status).toBe(404)
}
})
it('answers 404 for a missing artifact, an unknown kind and an unknown reel', async () => {
const { reel } = await createReel({ status: 'published' })
expect(
(await callRoute({ reelId: reel.id, kind: 'narration', user: communicator })).status,
).toBe(404)
expect((await callRoute({ reelId: reel.id, kind: 'poster', user: communicator })).status).toBe(
404,
)
expect((await callRoute({ reelId: 999999, kind: 'video', user: communicator })).status).toBe(
404,
)
})
it('keeps delete out of the advisor and in the communication roles', async () => {
const { reel } = await createReel({ status: 'published' })
await expect(
payload.delete({
collection: 'reel',
id: reel.id,
user: advisor,
overrideAccess: false,
}),
).rejects.toThrow()
await expect(
payload.delete({
collection: 'reel',
id: reel.id,
user: communicator,
overrideAccess: false,
}),
).resolves.toBeTruthy()
createdReelIds.delete(reel.id)
})
})

View file

@ -0,0 +1,136 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
canServeReelMedia,
isReelMediaKind,
isReelStatus,
REEL_FEATURES,
REEL_MEDIA_KINDS,
REEL_STATUSES,
reelFeatureLabels,
reelMediaFieldByKind,
reelMediaPath,
reelStatusLabels,
} from '@/lib/reel'
import {
REEL_MEDIA_FALLBACK_MIME_TYPE,
reelMediaContentDisposition,
reelMediaContentType,
reelMediaHeaders,
type ReelMediaRange,
} from '@/lib/reelMedia'
const fullRange: ReelMediaRange = {
status: 200,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '100' },
}
const partialRange: ReelMediaRange = {
status: 206,
headers: { 'Accept-Ranges': 'bytes', 'Content-Length': '10', 'Content-Range': 'bytes 0-9/100' },
}
const invalidRange: ReelMediaRange = {
status: 416,
headers: { 'Content-Range': 'bytes */100' },
}
describe('reel vocabulary (C193)', () => {
it('pins the status kill switch and its labels', () => {
expect(REEL_STATUSES).toEqual(['draft', 'published', 'unpublished'])
expect(Object.keys(reelStatusLabels).sort()).toEqual([...REEL_STATUSES].sort())
expect(isReelStatus('published')).toBe(true)
expect(isReelStatus('processing')).toBe(false)
})
it('pins the closed feature catalog starting at cards', () => {
expect(REEL_FEATURES).toEqual(['cards'])
expect(Object.keys(reelFeatureLabels).sort()).toEqual([...REEL_FEATURES].sort())
})
it('maps every artifact kind to exactly one upload field', () => {
expect(REEL_MEDIA_KINDS).toEqual(['video', 'video-audio', 'narration', 'captions', 'cover'])
expect(Object.keys(reelMediaFieldByKind).sort()).toEqual([...REEL_MEDIA_KINDS].sort())
expect(new Set(Object.values(reelMediaFieldByKind)).size).toBe(REEL_MEDIA_KINDS.length)
expect(reelMediaFieldByKind.video).toBe('video')
expect(isReelMediaKind('captions')).toBe(true)
expect(isReelMediaKind('poster')).toBe(false)
})
it('serves only a published reel and builds the cookie-scoped path', () => {
expect(canServeReelMedia('published')).toBe(true)
expect(canServeReelMedia('draft')).toBe(false)
expect(canServeReelMedia('unpublished')).toBe(false)
expect(reelMediaPath(42, 'video')).toBe('/campanha/comunicacao/reels/42/media/video')
})
})
describe('reel media response rules (C193)', () => {
it('serves only allowlisted types inline; everything else degrades', () => {
expect(reelMediaContentType('video/mp4')).toBe('video/mp4')
expect(reelMediaContentType('audio/mpeg')).toBe('audio/mpeg')
expect(reelMediaContentType('image/jpeg')).toBe('image/jpeg')
expect(reelMediaContentType('text/html')).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
expect(reelMediaContentType(null)).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
})
it('builds both filename forms and encodes non-ASCII', () => {
expect(reelMediaContentDisposition('reel.mp4', false)).toBe(
'inline; filename="reel.mp4"; filename*=UTF-8\'\'reel.mp4',
)
expect(reelMediaContentDisposition('capa ção.png', true)).toBe(
'attachment; filename="capa_o.png"; filename*=UTF-8\'\'capa%20%C3%A7%C3%A3o.png',
)
expect(reelMediaContentDisposition(null, false)).toContain('filename="arquivo"')
})
it('keeps the range headers and adds the private streaming contract', () => {
const headers = reelMediaHeaders({
range: partialRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes 0-9/100')
expect(headers.get('Content-Length')).toBe('10')
expect(headers.get('Accept-Ranges')).toBe('bytes')
expect(headers.get('Content-Type')).toBe('video/mp4')
expect(headers.get('Content-Disposition')).toContain('inline')
expect(headers.get('Cache-Control')).toBe('private, no-store')
expect(headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(headers.get('Content-Security-Policy')).toBeNull()
})
it('forces a download for unsafe types and for ?download=1', () => {
const unsafe = reelMediaHeaders({
range: fullRange,
mimeType: 'text/html',
filename: 'roteiro.html',
download: false,
})
expect(unsafe.get('Content-Type')).toBe(REEL_MEDIA_FALLBACK_MIME_TYPE)
expect(unsafe.get('Content-Disposition')).toContain('attachment')
expect(unsafe.get('Content-Security-Policy')).toBe("default-src 'none'")
const explicit = reelMediaHeaders({
range: fullRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: true,
})
expect(explicit.get('Content-Disposition')).toContain('attachment')
})
it('passes the 416 headers through untouched', () => {
const headers = reelMediaHeaders({
range: invalidRange,
mimeType: 'video/mp4',
filename: 'reel.mp4',
download: false,
})
expect(headers.get('Content-Range')).toBe('bytes */100')
expect(headers.get('Content-Type')).toBe('video/mp4')
})
})