20 KiB
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
reelna 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), comstatusdraft | published | unpublishedfuncionando como kill switch. Os arquivos são privados: só abrem com login de campanha;unpublisheddeixa 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.readpúblico intocado (src/collections/Media.ts:16-21, pino emtests/int/collectionAccessLockdown.int.spec.ts:224-249). - Site público e
#cardsidênticos; nenhuma URL pública nova. - Sem publicação automática no Instagram, sem segundo cadastro de pessoa, sem clone de voz, sem
Consentnovo (mídia interna de staff, sem PII). - Papéis da comunicação (
communicator/coordinator/candidate);advisor/leadernegados fail-closed. - Arquivos aceitos como enviados: sem transcode, compressão, thumb derivada ou versionamento.
- Privacidade do arquivo: anônimo e navegador sem sessão recebem não;
- O que reavaliar:
revalidateDocumentByIdnoafterChange/afterDeletedoreel: não há cache público nemunstable_cachedo 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 "
unpublishedesconde 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
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 porgetCampaignUser()+canReadCommunicationCatalog; carrega oreelcom Local APIoverrideAccess: false, user; streama o objeto direto (S3GetObjectCommandcomRange, ou disco local). ReusaparseRangeHeader/getRangeRequestInfodepayload/internal(os mesmos helpers do handler local/S3,node_modules/payload/dist/uploads/endpoints/getFile.js:19,51-53). O cookiecampaign-tokentempath='/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 porAuthorization.- B — URL assinada S3 (rejeitada):
signedDownloadsaponta para o endpoint interno (host.docker.internal:3900), inalcançável pelo browser. - C — self-fetch para
/api/reelMedia/file/...comAuthorization: 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.
- B — URL assinada S3 (rejeitada):
- Modelo — A (recomendada): collection
reel(group'Comunicação') + collection de upload privadareelMedia, separada demedia. 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: reusarmedia(quebra leitura anônima) e um único campo upload com arquivos mistos (perde discriminação de artefato, tipo e downloads porkindque o C194 consome). - RBAC — A (recomendada): renomear
canReadSpeechCatalog→canReadCommunicationCatalogemsrc/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 twincanReadReelCatalog(duplicaria o predicado — fere "edit the owner, don't twin"). - Feature — A (recomendada): lista fechada em
src/lib/reel.ts, começando porcards; 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): slugreel; labels singularReel, pluralReels;admin.group: 'Comunicação';useAsTitle: 'title';defaultColumns: ['title', 'feature', 'status', 'publishedAt'];admin.descriptionexplicando que os arquivos são privados e que despublicar tira do ar. Campos:title(text, required,maxLengthdesrc/lib/reel.ts);feature(select, required, options deREEL_FEATURES);status(select, required, defaultdraft, index, options deREEL_STATUSES,admin.descriptiondo 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 decanDeleteSpeechCutemsrc/utilities/access/speeches.ts:39-44). Hooks:beforeChange: [stampCampaignCreatedBy]. Dispensa derevalidateDocumentById: oreelnão é lido porgetCachedDocumentById/unstable_cachenem renderizado em RSC público; não há tag a bustar. Reavaliar se o C194 introduzir leitura cacheada.ReelMedia(src/collections/ReelMedia.ts, novo): slugreelMedia; labels singularMídia de reel, pluralMídias de reel;admin.group: 'Comunicação';upload: true; campoalt(text, required); accessread/create/update=canReadReel,delete=canDeleteReel. Não entra em nenhuma superfície pública (sem rota/api/reelMedia/fileconsumida pelo site). Justificativa da separação versusmedia: o bucket é privado via proxy, mas o access demediaé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 mapakind → 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): renomearcanReadSpeechCatalog→canReadCommunicationCataloge atualizar o comentário; nenhuma mudança de comportamento.src/utilities/access/reels.ts(novo):canReadReelecanDeleteReel, forma despeeches.ts—isPayloadAdmin(req.user)→true;getFreshCampaignUser(req)→canReadCommunicationCatalog(role); senãofalse.canDeleteReelé predicado próprio (nunca alias decanReadReel), para que ampliar leitura não conceda delete. Reexport no barrelsrc/utilities/campaignAccess.ts(bloco apósspeeches, linhas ~172-177).src/utilities/reels/reelMediaResponse.ts(novo, subpasta de domínio — o top-level desrc/utilities/é pinado portests/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ãopath.resolve(staticDir || 'reelMedia', filename)), comparseRangeHeader/getRangeRequestInfopara 206/416.@aws-sdk/client-s3já é dependência direta (package.json), usada emscripts/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); validaidinteiro ekind ∈ REEL_MEDIA_KINDS; Local APIfinddoreelcomoverrideAccess: false, user,depth: 0; kill switch → só serve quandostatus === 'published'(draft/unpublished→ 404); resolve o upload do campo dokind(vazio → 404); delega aoreelMediaResponse. 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): importarReel/ReelMedia, registrar emcollections(junto deMedia, linhas 100-133) e adicionarreelMedia: trueaos3Storage({ collections: { media: true, reelMedia: true } })(linhas 160-178). Sem isso,reelMediacai em disco local mesmo com S3 ligado — inaceitável em prod. O plugin é condicionado amediaStorage.enabled(resolveS3StorageEnv, fail-closed).- Migration:
pnpm migrate:create add_reel— criareel,reel_media, enums (enum_reel_status,enum_reel_feature), colunas de FK parareel_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 sempreoverrideAccess: false+user;Media.readintocado. SemConsent— 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:
reelfiltrado porstatus: published(a biblioteca) e o detalhe com oskinds 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
- Puro + RBAC (unit). Criar
src/lib/reel.ts(constantes, labels, mapakind → campo, content types) e renomearcanReadSpeechCatalog→canReadCommunicationCatalogcom todas as referências e o barrel. Unit: boundary das listas, mapakind → campo, parser puro de headers (Range/download/Cache-Control), comportamento do predicado renomeado. - Collections + access + storage. Criar
Reel/ReelMedia,src/utilities/access/reels.ts+ reexport, registrar empayload.config.tse nos3Storage.collections. EditarcampaignAccess.ts.pnpm generate:types. - Migration (HARD-STOP de aprovação humana).
pnpm migrate:create add_reel→ revisar SQL (tabelas, enums, FKs, rels, índices) → aprovação registrada →pnpm migratelocal. Nunca editar migrações antigas. - Serving privado. Criar
src/utilities/reels/reelMediaResponse.tse 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). - Testes int + e2e (decisão).
tests/int/reel.int.spec.tsetests/int/reelMedia.int.spec.tsseguindotests/int/speechCut.int.spec.ts(mock denext/cacheconforme :22-26). Registrar que e2e de fluxo fica no C194 (sem UI); a mudança emsrc/utilities/accesse emsrc/lib/campaignRolesjá acorda os specs curados de RBAC/comunicação pelo manifest (scripts/lib/e2e-affected-manifest.mjs:254,310-313). - Gates —
pnpm gate:fast; push viapnpm push.
Hard-stops (aprovação humana explícita)
- Migração de schema (Fase 3): aprovada na sessão
--auto(2026-09-18) para criar/aplicaradd_reelsó no banco local do worktree; nunca editar migrações antigas, nunca tocar banco remoto/prod. unpublishedinvalida 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.- 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
Mediaou mudar seu access; rota pública nova;Consentnovo.
Riscos e mitigação
- Vazamento de arquivo privado por
media/rota pública. Mitigação:reelMediaseparada, sem superfície pública;Media.readintocado e pinado no lockdown; rota sempre gated + Local APIoverrideAccess: false. - S3 ausente/parcial. Mitigação:
resolveS3StorageEnvfail-closed; dev/test caem em disco local. Risco residual: esquecerreelMedia: trueno 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 porgetCampaignUser(); sem depender de header em<video>. - Streaming incorreto (Range/206/416/Content-Length). Mitigação: reusar
parseRangeHeader/getRangeRequestInfodopayload/internal(mesma semântica do plugin) e isolar o cálculo puro em unit comRangeinválido/aberto/fechado. - Kill switch com cache. Mitigação:
Cache-Control: private, no-storeem toda resposta; semunstable_cachedo 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_TYPESexportado sem consumidor (quebravapnpm knip) → não exportado.mimeType as stringredundante emreelMediaContentType→isInlineSafevirou type predicate.- Spread
...(required ? { required: true } : {})no campo de upload →requireddireto. - Literal
'reelMedia'duplicado (slug/staticDir/rota) →REEL_MEDIA_SLUGemsrc/lib/reel.ts. - 404 da rota e do utilitário com
Cache-Controldivergentes →REEL_MEDIA_CACHE_CONTROLúnico. isMissingObjectchecavahttpStatusCode(inexistente no AWS SDK v3) →$metadata.httpStatusCode.- Objeto S3 de 0 byte virava 500 enquanto o disco servia 200 → alinhado (só
ContentLengthnulo falha). - Comentário desatualizado em
nav.ts; variável de fixturenarrationguardando.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):reelMediaPatheisReelStatussem 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):
ReelMediaFieldreescreve os nomes dos uploads em vez de derivarkeyof Reelde@/payload-types. Gatilho: rename de campo doReelou o C194 passando a lerreelMediaFieldByKind— então tipar o mapa contrakeyof Reel. - Descarte: gate key
'speechCatalog'emcampaignPageActor.ts(rename de pureza; se o C194 tocar o gate, renomeia de carona). - Descarte: corpos idênticos de
canReadReel/canDeleteReel(duplicação deliberada, precedentecanDeleteSpeechCut— nunca alias de leitura). - Descarte: fallback defensivo de
staticDirna 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;
unpublishedsome 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+userem toda leitura; semConsentnovo;Media.readintocado; 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 pushcomo 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.