mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(C193): reel privado no CMS — modelo, upload privado e kill switch (Closes #1152)
This commit is contained in:
parent
45eef25b1a
commit
bdaf3c7cbb
27 changed files with 17450 additions and 28 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
1
docs/changelog/2026-09-18-c193.md
Normal file
1
docs/changelog/2026-09-18-c193.md
Normal 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).
|
||||||
54
docs/plans/reels-privado-hardening.md
Normal file
54
docs/plans/reels-privado-hardening.md
Normal 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.
|
||||||
151
docs/plans/reels-reel-privado-impl.md
Normal file
151
docs/plans/reels-reel-privado-impl.md
Normal 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.
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
157
src/collections/Reel.ts
Normal 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 }),
|
||||||
|
],
|
||||||
|
}
|
||||||
44
src/collections/ReelMedia.ts
Normal file
44
src/collections/ReelMedia.ts
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
66
src/lib/reel.ts
Normal 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
66
src/lib/reelMedia.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
15963
src/migrations/20260918_201009_add_reel.json
Normal file
15963
src/migrations/20260918_201009_add_reel.json
Normal file
File diff suppressed because it is too large
Load diff
82
src/migrations/20260918_201009_add_reel.ts
Normal file
82
src/migrations/20260918_201009_add_reel.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
34
src/utilities/access/reels.ts
Normal file
34
src/utilities/access/reels.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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' } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -175,3 +175,5 @@ export {
|
||||||
canReadSpeechCut,
|
canReadSpeechCut,
|
||||||
canUpdateSpeech,
|
canUpdateSpeech,
|
||||||
} from '@/utilities/access/speeches'
|
} from '@/utilities/access/speeches'
|
||||||
|
|
||||||
|
export { canDeleteReel, canReadReel } from '@/utilities/access/reels'
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
173
src/utilities/reels/reelMediaResponse.ts
Normal file
173
src/utilities/reels/reelMediaResponse.ts
Normal 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
290
tests/int/reel.int.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
136
tests/unit/reel.unit.spec.ts
Normal file
136
tests/unit/reel.unit.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue