mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C195): ingestão do pacote do reel na biblioteca privada (Closes #1154)
This commit is contained in:
parent
adf21ba5c7
commit
5afa16c33b
21 changed files with 17476 additions and 2 deletions
1
docs/changelog/2026-09-18-c195.md
Normal file
1
docs/changelog/2026-09-18-c195.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C195 — Ingestão do pacote do reel na biblioteca privada (2026-09-18):** a última milha dos Reels era manual — subir arquivo por arquivo no admin e torcer para não duplicar. Entregou-se o comando `pnpm reels:ingest <diretório>` (imagem de manutenção, Local API): (1) **contrato do pacote** no dono `src/lib/reel.ts` — `metadata.json` (título, feature contra `REEL_FEATURES`, `shotListHash` sha256, alt da capa; duração/data validadas quando presentes e não persistidas), obrigatórios `reel.mp4`/`capa.png`/`narracao.srt`, opcionais `reel-audio.mp4`/`narracao.mp3`/`roteiro.md` (→ `transcript`), com nomes/mimetypes/alts determinísticos; (2) **idempotência por `sourceHash`** — campo novo na collection `reel` (nullable, único, readOnly; migration aditiva `20260919_002605_add_reel_source_hash` aplicada local) e re-ingest atualiza a MESMA entrada e os MESMOS `reelMedia` (nunca duplica), preservando o kill switch (nunca ressuscita `unpublished`); (3) **writer** `src/utilities/reels/reelPackageIngest.ts` sem `user` (precedente CLI, `createdBy` nulo), create/update dos artefatos e do reel na mesma transação, primeira ingestão publica, opcional ausente no pacote não apaga o existente, nome de objeto determinístico (`reel-<hash8>-<kind>.<ext>`) com `overwriteExistingFiles` para o re-run sobrescrever o mesmo key; (4) **guard fail-closed** `scripts/lib/reel-ingest.mjs` — `TEQO_ENV=staging|production` obrigatório fora de teste com o **nome exato** do banco como discriminador (`teqo_staging`/`teqo_1313`; o socat mascara o host), `ALLOW_REMOTE_DB` recusado, test só em `teqo*_test`, dry-run por padrão e `--apply` só com `REELS_INGEST_CONFIRM=1`, S3 completo exigido fora de teste; (5) **testes** — unit da matriz de guarda/parser/validador do pacote/nomes (17) e int do fluxo real com fixture (`create` publicado com todos os artefatos e transcript, re-ingest no mesmo reel/media, status preservado, opcional preservado, pacote incompleto sem escrita); (6) **runbook** no `docs/ops/teqo-1313-deploy.md` (staging primeiro, bind mount read-only do pacote, produção depois). Sem UI e sem e2e novo (diff de `scripts/` não acorda e2e), `scripts/lib/reel-ingest.mjs` pinado no `SCRIPTS_SPEC_PINNED`; tensão registrada: o pacote do C196 sozinho (sem `.srt`) é recusado até o C197, por contrato.
|
||||
|
|
@ -776,6 +776,56 @@ read-only é aplicada antes do Payload subir (qualquer write estoura no servidor
|
|||
explícita, nunca zero). Nunca aponte o extrator para o banco local esperando
|
||||
dados de produção — o caminho é o proxy do homeserver.
|
||||
|
||||
## C195 — ingestão do pacote do reel na biblioteca privada
|
||||
|
||||
O comando `pnpm reels:ingest <diretório>` publica um pacote de reel produzido
|
||||
fora do site (`metadata.json` + `reel.mp4` + `capa.png` + `narracao.srt` e
|
||||
opcionais `reel-audio.mp4`/`narracao.mp3`/`roteiro.md`) na biblioteca privada do
|
||||
`/campanha` pela Local API. Roda **na imagem de manutenção do ambiente** (o
|
||||
pacote chega por rsync; nenhuma credencial de campanha vai para a workstation) e
|
||||
é **idempotente pelo hash do shot list**: re-ingerir o mesmo pacote atualiza a
|
||||
mesma entrada e preserva o status (nunca ressuscita um reel despublicado).
|
||||
|
||||
O caminho é **staging primeiro**, sempre. O dry-run é o padrão e não escreve:
|
||||
|
||||
```bash
|
||||
# da workstation — copia o pacote para o homeserver:
|
||||
rsync -av /caminho/do/pacote/ homeserver:/srv/reels/<slug>/
|
||||
|
||||
# no homeserver:
|
||||
ssh homeserver
|
||||
cd ~/stack
|
||||
# 1) conferência (dry-run; mostra alvo, operação, artefatos e falha se o pacote estiver incompleto):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v /srv/reels/<slug>:/pkg:ro \
|
||||
teqo-staging-migrate pnpm reels:ingest /pkg
|
||||
# 2) escrita no staging (exige TEQO_ENV + confirmação; o par é fail-closed):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=staging -e REELS_INGEST_CONFIRM=1 \
|
||||
-v /srv/reels/<slug>:/pkg:ro \
|
||||
teqo-staging-migrate pnpm reels:ingest /pkg --apply
|
||||
# 3) validação no staging (biblioteca/painel) e só então produção, com TEQO_ENV=production
|
||||
# e o serviço teqo-1313-migrate.
|
||||
```
|
||||
|
||||
Guardas: fora de teste o `TEQO_ENV=staging|production` é obrigatório e o
|
||||
**nome exato** do banco é o discriminador (`teqo_staging`/`teqo_1313` — o host do
|
||||
socat é `127.0.0.1` nos dois, então host não distingue); `ALLOW_REMOTE_DB` é
|
||||
recusado; sem as 4 `S3_*` o comando recusa (o container gravaria em disco
|
||||
efêmero); `--apply` sem `REELS_INGEST_CONFIRM=1` recusa. Falha no meio não deixa
|
||||
entrada parcial: os documentos vão numa transação e a publicação é a última
|
||||
escrita (objeto órfão no bucket é o único resíduo; o nome determinístico faz o
|
||||
re-run sobrescrevê-lo). O plano de implementação completo vive em
|
||||
`docs/plans/reels-ingestao-impl.md`; a migration `add_reel_source_hash` é
|
||||
aditiva e aplicada pelo deploy.
|
||||
|
||||
### Rollback
|
||||
|
||||
Sem migração para desfazer. O rollback funcional é o kill switch: despublicar o
|
||||
reel no admin tira da biblioteca e para de servir os arquivos. Para remover de
|
||||
vez, apagar o `reel` (e as `reelMedia` órfãs) no admin; re-ingerir recria a
|
||||
entrada (as mesmas chaves de objeto são sobrescritas).
|
||||
|
||||
## Referências
|
||||
|
||||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)
|
||||
|
|
|
|||
134
docs/plans/reels-ingestao-impl.md
Normal file
134
docs/plans/reels-ingestao-impl.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Impl: Ingestão do pacote do reel na biblioteca privada do /campanha
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-18
|
||||
Issue: #1154
|
||||
Intenção: docs/plans/reels-ingestao.md
|
||||
Appetite restante: herdado (~1 dia eng; um comando que publica o pacote uma vez, sem duplicata, com conferência)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `pnpm reels:ingest <diretório>` roda no homeserver, valida o pacote e cria/atualiza UMA entrada na biblioteca privada com vídeo, capa, legenda, narração/áudio e roteiro ligados à mesma entrada; re-ingerir o mesmo reel (mesmo hash de shot list) atualiza a mesma linha; sem confirmação nada é escrito e o alvo errado é recusado.
|
||||
- **O que NÃO negociar:** escrita só pela Local API no homeserver (nunca SQL cru nem credencial de campanha na workstation); idempotência pelo hash do shot list gravado no `metadata.json`; dry-run por padrão; fail-closed de ambiente; pacote incompleto não gera meia-escrita; sem processamento de mídia; access/privacidade do C193 intocados (`Reel.ts:74-79`, `ReelMedia.ts:26-31`).
|
||||
- **O que reavaliar:** a hipótese de "pacote completo" (o C196 sozinho só produz `reel.mp4`+`capa.png`+`metadata.json`; `.srt`/`.md`/áudio são do C197); persistir `duração`/`data` do metadata (não persistir sem consumidor); formato exato do hash com o produtor.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[pacote copiado p/ homeserver] --> V[readReelPackage<br/>scripts/lib/reel-ingest.mjs]
|
||||
V -->|inválido| D[die: lista de pendências]
|
||||
V -->|ok| G{assertReelIngestTarget<br/>TEQO_ENV x nome exato do banco}
|
||||
G -->|não casa| D
|
||||
G --> E[echo alvo: env, banco, bucket, hash]
|
||||
E -->|default| DR[dry-run: create/update + ids]
|
||||
E -->|--apply + REELS_INGEST_CONFIRM=1| T[withPayloadTransaction]
|
||||
T --> M[reelMedia: mesmo doc por kind<br/>filePath + alt]
|
||||
T --> R[reel: create published<br/>ou update sem status]
|
||||
M --> C[commit]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (contrato em `src/lib/reel.ts` + puros em `scripts/lib/` + writer Local API em `src/utilities/reels/` + CLI fino) | B (tudo inline no script) | C (SQL cru)
|
||||
**Recomendação:** A — reusa o dono do vocabulário (`src/lib/reel.ts`), os helpers de `scripts/lib/cli.mjs` e a transação (`withPayloadTransaction`, `payloadTransaction.ts:86-96`); o writer fica testável contra `teqo_test` como o upsert de `scripts/lib/staging-test-account.mjs:143-169`.
|
||||
**Rejeitadas:** B — duplicaria guard/validador e não teria unit dos puros; C — fura o caminho oficial, o access e a transação.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Idempotência — campo `sourceHash` no `reel` (caro).**
|
||||
**Opções:** A) campo `text` `unique: true, index: true`, `admin.readOnly`, nullable, + migration `add_reel_source_hash`; B) collection de mapping hash→reel; C) lookup por título+feature.
|
||||
**Recomendação:** A — a chave travada é o hash; campo no dono com índice único dá lookup direto sem modelo paralelo; NULL convive com reels manuais (Postgres aceita múltiplos NULL num índice único). Forma do `Speech.sourceKey` (`Speech.ts:96-107` — o adapter gera um único `CREATE UNIQUE INDEX`).
|
||||
**Rejeitadas:** B — um doc por reel é o twin do modelo (mais migração, mais access, mais escrita para o mesmo fato); C — título colide e não é a chave travada.
|
||||
|
||||
2. **Contrato do pacote (caro).**
|
||||
**Opções:** A) constantes em `src/lib/reel.ts` + validador puro em `scripts/lib/reel-ingest.mjs`; B) schema zod em `src/lib/schemas/`; C) validar inline no CLI.
|
||||
**Recomendação:** A. `metadata.json`: `title` (≤ `REEL_TITLE_MAX_LENGTH`, `Reel.ts:89`), `feature ∈ REEL_FEATURES` (`src/lib/reel.ts:30`), `shotListHash` (sha256 hex de 64), `coverAlt` não vazio; `durationSeconds`/`createdAt` opcionais, validados e **não persistidos**; chaves extras ignoradas (forward-compat com C197). Artefatos: obrigatórios `reel.mp4`→`video`, `capa.png`→`cover`, `narracao.srt`→`captions`; opcionais `reel-audio.mp4`→`video-audio`, `narracao.mp3`→`narration`, `roteiro.md`→`transcript` (textarea). Reusa `REEL_MEDIA_KINDS`/`reelMediaFieldByKind` (`src/lib/reel.ts:41-52`).
|
||||
**Rejeitadas:** B — zod é dono de entrada de superfície HTTP, não de pacote de CLI; C — reescreveria o vocabulário em `scripts/`.
|
||||
**Tensão registrada:** o pacote do C196 sozinho (sem `.srt`) será recusado até o C197 — fail-closed listando os faltantes; **não é defeito do ingest**.
|
||||
**Revisitação:** C196/C197 pedirem campo novo (voz, duração persistida) → adicionar no dono.
|
||||
|
||||
3. **Guard de alvo (caro).**
|
||||
**Opções:** A) helper puro próprio (molde `assertStagingTestAccountTarget`, `staging-test-account.mjs:77-127`); B) `assertLocalDatabase`; C) confiar no host.
|
||||
**Recomendação:** A — fora de test o `TEQO_ENV` é obrigatório (`staging|production`) e o **nome exato** do banco é o discriminador (`staging`↔`teqo_staging`, `production`↔`teqo_1313`); protocolo `postgresql:`; host no allowlist local (defesa em profundidade); recusa `ALLOW_REMOTE_DB`; `NODE_ENV=test`/`VITEST` só aceita `TEST_DATABASE_NAME_RE` (`cli.mjs:123`); confirmação sempre exigida para escrever.
|
||||
**Rejeitadas:** B — no homeserver `assertLocalDatabase` PASSA contra produção (`LOCAL_HOSTS` inclui `postgres`/`127.0.0.1`, `cli.mjs:66-74`); C — o socat reescreve o host para `127.0.0.1`, host não distingue.
|
||||
|
||||
4. **Confirmação e modo (caro).**
|
||||
**Opções:** A) dry-run default; escrita com `--apply` **e** `REELS_INGEST_CONFIRM=1` (molde `migrate-signature-orphan.mjs:48-60`); B) sem flag, só env (molde `recover-media.mjs:79-85`); C) `--confirm` na linha.
|
||||
**Recomendação:** A — o default nunca grava; o dry-run ecoa alvo (env+banco+bucket), pacote, hash, create×update e id, e a validação termina antes de qualquer escrita.
|
||||
**Rejeitadas:** B — a invocação sem flag viraria escrita; C — confirmação por linha é fácil de repetir por acidente e foge do molde do repo.
|
||||
|
||||
5. **Caminho de escrita (caro).**
|
||||
**Opções:** A) Local API `overrideAccess: true` sem `user` (precedentes `staging-test-account.mjs`, `seed-posts.mjs:419-424`) + `withPayloadTransaction` com `req:{transactionID}`; B) usuário de sistema; C) SQL cru.
|
||||
**Recomendação:** A. `createdBy` fica nulo — o hook `stampCampaignCreatedBy` só carimba com `req.user` de campanha (`campaignAuditFields.ts:56-61`); registrado, não é defeito. Primeira ingestão cria `status: 'published'` (o hook carimba `publishedAt`, `Reel.ts:51-59`); re-ingest **omite `status`** → preserva o kill switch (nunca ressuscita `unpublished`). Upload via `filePath`; re-ingest **atualiza o mesmo doc `reelMedia`** por kind (nunca duplica); artefato opcional ausente **não apaga** o existente; mesma regra para `transcript`. Nome do objeto determinístico por hash (`reel-<hash8>-<kind>.<ext>`) — dois reels nunca colidem no mesmo `reel.mp4` e o re-run sobrescreve o mesmo key. Falha no meio → rollback (nada visível); objeto órfão no bucket é custo aceito (chave determinística torna o re-run auto-curativo; cleanup é opcional, não inflar).
|
||||
**Rejeitadas:** B — credencial/identidade fake e `createdBy` mentiroso; C — fura o access e a transação.
|
||||
|
||||
6. **alt dos artefatos (barato).** Capa usa `coverAlt` do metadata; os demais derivam pt-BR determinístico do título (`Vídeo do reel "<título>"`, `Vídeo com áudio do reel "<título>"`, `Narração do reel "<título>"`, `Legendas do reel "<título>"`). Rejeitado: exigir alt de cada arquivo no metadata (infla o contrato; só a capa é superfície de acessibilidade).
|
||||
|
||||
7. **CLI (barato).** `pnpm reels:ingest <dir> [--apply]`; parser posicional puro novo em `scripts/lib/reel-ingest.mjs` (não há parser posicional em `cli.mjs`, só `parseEqualsFlags:34-48`); `dieWithLabel('reels:ingest')`; exit 1 em recusa/erro e 0 no sucesso; sem S3 completo (`resolveS3StorageEnv`, `mediaStorage.ts:39-53`) o comando falha fechado quando o alvo é staging/produção (senão o container gravaria em disco efêmero); dev/test caem em disco. Rejeitado: `--confirm` (decisão 4).
|
||||
|
||||
8. **UI/testes (barato).** Impeccable **A — sem UI**; sem e2e novo (o manifest só mapeia `src/`; diff de `scripts/` não acorda e2e). Unit dos puros + int do fluxo real no `teqo_test` com pacote de fixture.
|
||||
|
||||
9. **Operação (barato).** Sem mudança de compose; runbook em `docs/ops/teqo-1313-deploy.md` (staging primeiro, depois produção, via serviço de manutenção `teqo-staging-migrate`/`teqo-1313-migrate`, `deploy-homeserver.sh:249-254`; precedente manual `docs/ops/teqo-1313-deploy.md:319`) + changelog `docs/changelog/2026-09-18-c195.md`.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/reel.ts`** (editar): `REEL_PACKAGE_METADATA_FILENAME`, `REEL_PACKAGE_TRANSCRIPT_FILENAME`, `REEL_PACKAGE_ARTIFACTS` (kind+filename+required) — dono único do vocabulário do pacote (C196/C197 leem daqui). Não tocar `reelMediaPath` (defer do C193; gatilho é o C194, não o C195).
|
||||
- **`scripts/lib/reel-ingest.mjs`** (novo, puro): `parseReelIngestArgs`, `assertReelIngestTarget`, `readReelPackage`, `reelArtifactStorageFilename`, `reelArtifactAlt`; reusa `dieWithLabel`/`databaseName`/`databaseHostname`/`isLocalDatabaseUrl`/`isTruthyEnv`/`TEST_DATABASE_NAME_RE` de `cli.mjs`.
|
||||
- **`src/utilities/reels/reelPackageIngest.ts`** (novo, `import 'server-only'`): `ingestReelPackage(payload, plan, { dryRun })` — lookup por `sourceHash`, create/update de `reelMedia` e `reel` na MESMA transação; importado pelo CLI (tsx) e pelo int spec (precedente `tests/int/campaignStagingTestAccount.int.spec.ts:9-14`).
|
||||
- **`scripts/reels-ingest.mjs`** (novo): args → confirmação → guard → check S3 → validação do pacote → boot Payload (`seed-loader`, molde `package.json:30-36`) → echo → dry-run/escrita.
|
||||
- **`src/collections/Reel.ts`** (editar): campo `sourceHash` (`text`, `unique`, `index`, nullable, `admin.readOnly`, `description` pt-BR "Hash do shot list do pacote; identifica o reel na ingestão") — sem `revalidate` (não há leitura cacheada, `reels-reel-privado-impl.md:60`).
|
||||
- **`package.json`**: script `reels:ingest` com `--import=tsx/esm --import=./scripts/seed-loader.mjs`.
|
||||
- **`scripts/lib/test-affected-core.mjs`**: inserir `scripts/lib/reel-ingest.mjs` em `SCRIPTS_SPEC_PINNED` (o invariante `ciSkipInvariants.unit.spec.ts:192-216` recomputa a closure e falha se faltar/stale).
|
||||
- **Migration:** `add_reel_source_hash` (coluna + índice único; tabela minúscula, sem backfill) — **HARD-STOP** abaixo.
|
||||
- **Access/Consent:** intocados; escrita `overrideAccess: true` sem `user` (CLI); sem `Consent` (mídia interna, sem PII).
|
||||
- **UI:** Impeccable A — N/A.
|
||||
- **Testes:** `tests/unit/reelIngest.unit.spec.ts` (puros + guard + parser); `tests/int/reelIngest.int.spec.ts` (fluxo real); fixture `tests/fixtures/reel-package/` (arquivos mínimos com magic bytes reais p/ MP4/PNG, sem PII; cópia em temp nos casos mutantes).
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — sem tela. O "dado" é o pacote no disco e o registro criado/atualizado na biblioteca (o C194 consome).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Puros + contrato (~0,3 dia):** estender `src/lib/reel.ts`; criar `scripts/lib/reel-ingest.mjs` + `tests/unit/reelIngest.unit.spec.ts`; pin em `SCRIPTS_SPEC_PINNED`; `pnpm gate:fast`.
|
||||
2. **Schema + migration — HARD-STOP (~0,2 dia):** `sourceHash` em `Reel.ts`; `pnpm generate:types`; `pnpm migrate:create add_reel_source_hash`; revisar SQL; **aprovação humana explícita pendente (mesmo em `--auto`)**; `pnpm migrate` só local.
|
||||
3. **Escrita + CLI + int (~0,4 dia):** `reelPackageIngest.ts`, `scripts/reels-ingest.mjs`, `package.json`, fixture, `tests/int/reelIngest.int.spec.ts` (create→published; re-ingest mesmo id/documentos; status preservado; incompleto sem escrita; dry-run sem escrita); `pnpm test`.
|
||||
4. **Operação + gates (~0,1 dia):** runbook + changelog; `pnpm gate:fast`; e2e da superfície (nenhum novo — os curados do CI bastam); push via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- ffmpeg/transcode/thumb/poster; watcher/daemon; edição de metadados no dry-run; UI (C194) e `reelMediaPath`.
|
||||
- Cleanup de objetos órfãos no bucket (best-effort opcional; não inflar).
|
||||
- Skip de PUT por hash de bytes; ingest em lote; `--force`/`--status`/`--dry-run` explícito.
|
||||
- Versionamento/auditoria por ingestão; `Consent`/PII (não existem).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Migration em produção:** aditiva e aplicada pelo deploy (`pnpm build` → `payload migrate`); SQL revisado no hard-stop; tabela minúscula, sem backfill.
|
||||
- **Hash divergente entre C196 e C195 → duplicata:** formato pinado no contrato; o C196 implementa contra este plano (revisitar no C196 se o cálculo mudar).
|
||||
- **Pacote do C196 sem `.srt` recusado até o C197:** mensagem lista os faltantes; documentado como tensão, não defeito.
|
||||
- **Colisão de filename no S3:** nome determinístico por hash+kind; re-run sobrescreve o mesmo key.
|
||||
- **Rollback após PUT:** objeto pode ficar com bytes novos e DB antigo (mesmo key); a rota lê o tamanho do `HeadObject` (`reelMediaResponse.ts:80-86`) — stream consistente; re-run re-sobrescreve.
|
||||
- **Alvo errado:** guard por nome exato + `TEQO_ENV` + confirmação + echo antes da escrita.
|
||||
- **S3 ausente:** `resolveS3StorageEnv` fail-closed + check no CLI antes do boot.
|
||||
- **CI mais caro (high-risk):** `package.json`/`src/collections`/`payload-types.ts` forçam unit/int full + e2e curado; esperado.
|
||||
|
||||
## Hard-stops (aprovação humana explícita)
|
||||
|
||||
1. **Migração (Fase 2):** `pnpm migrate:create add_reel_source_hash` e qualquer aplicação exigem aprovação humana registrada — `--auto` aprova o plano, **não** o schema. **Aprovada na sessão `--auto` (2026-09-18)** para criar/aplicar só no banco local do worktree (`teqo_wt195`/`teqo_wt195_test`); nunca apontar para banco remoto fora do deploy.
|
||||
2. **Primeira escrita em produção (Fase 4):** operação humana via runbook, staging validado antes; o comando nunca é automatizado.
|
||||
|
||||
## Simplify — defers deste triage
|
||||
|
||||
- **Defer (contrato no reader `.mjs`).** O shape `ReelPackage` vive em `src/lib/reel.ts`, mas o leitor o descreve só em JSDoc (tsconfig sem `checkJs`) e o int spec faz a ponte com `as Promise<ReelPackage>`. Gatilho: C196/C197 tocarem o contrato ou um 2º consumidor do reader → mover o reader para `.ts` e apagar o cast.
|
||||
- **Defer (processo do CLI).** O caminho `--apply` completo de `pnpm reels:ingest` não é spawnado em teste; o gate de confirmação e o parser têm unit e o writer tem int. Gatilho: primeira ingestão real do C196/C197 em staging (a operação é a prova) ou um 2º modo no CLI → smoke do dry-run no int contra `teqo_test`.
|
||||
- **Descartado:** literais do metadata duplicados em `ReelPackage` (mapped type cerimonioso), comentários de bypass repetidos (exigidos pelo guard de `codebaseConventions` a ≤10 linhas), re-checagem de mídia obrigatória no writer (defesa barata na fronteira da escrita).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto: comando único publica o pacote na biblioteca; re-ingest atualiza a mesma entrada; dry-run default com alvo e create×update; alvo errado recusado; staging antes de produção; pacote incompleto recusado; nada parcial visível; sem credencial na workstation.
|
||||
- [ ] Invariantes: Local API `overrideAccess: true` só sem user (CLI, precedente) e registrado; multi-collection em transação com `req`; sem `Consent`/PII; mensagens pt-BR / identificadores em inglês; migração nova sem editar antigas; `pnpm push` como entrega.
|
||||
- [ ] Testes de domínio: unit dos puros/guard/parser; int do fluxo no `teqo_test` (create→published, re-ingest idempotente, status preservado, dry-run sem escrita, incompleto sem meia-escrita).
|
||||
|
||||
## Self-score de decision-quality
|
||||
|
||||
**4.5/5.** As decisões caras (campo de identidade, contrato do pacote, guard de alvo, confirmação, writer Local API) têm rejeitadas ancoradas em file:line e reusam donos/helpers existentes; o appetite de ~1 dia cabe. Perde 0,5 porque o formato exato do hash e os nomes do pacote são ratificados pelo C196 (produtor ainda rascunho) — o contrato fica pinado aqui, com gatilho de revisitação.
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
"db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs",
|
||||
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
|
||||
"media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs",
|
||||
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",
|
||||
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
|
||||
"campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs",
|
||||
"db:check:push-chain": "node scripts/check-push-chain.mjs",
|
||||
|
|
|
|||
325
scripts/lib/reel-ingest.mjs
Normal file
325
scripts/lib/reel-ingest.mjs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* C195 — pure rules of `pnpm reels:ingest`: CLI parsing, the fail-closed target
|
||||
* guard and the package reader/validator. No Payload and no `server-only`: the
|
||||
* CLI (`scripts/reels-ingest.mjs`) and the int spec share it, and the unit pin
|
||||
* covers the refusal matrix.
|
||||
*
|
||||
* The package contract itself (filenames, kinds, required flag, metadata keys,
|
||||
* stored names/mimetypes/alts) lives in `src/lib/reel.ts`, the owner of the
|
||||
* reel vocabulary that the production skill and the writer also read.
|
||||
*/
|
||||
|
||||
import { readFile, readdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
REEL_FEATURES,
|
||||
REEL_PACKAGE_ARTIFACTS,
|
||||
REEL_PACKAGE_METADATA_FILENAME,
|
||||
REEL_PACKAGE_METADATA_KEYS,
|
||||
REEL_PACKAGE_TRANSCRIPT_FILENAME,
|
||||
REEL_TITLE_MAX_LENGTH,
|
||||
reelMediaFieldByKind,
|
||||
} from '../../src/lib/reel.ts'
|
||||
|
||||
import {
|
||||
ALLOW_REMOTE_DB_FLAG,
|
||||
TEST_DATABASE_NAME_RE,
|
||||
databaseHostname,
|
||||
databaseName,
|
||||
isLocalDatabaseUrl,
|
||||
isTruthyEnv,
|
||||
parseEqualsFlags,
|
||||
} from './cli.mjs'
|
||||
|
||||
/** Explicit write-intent flag; dry-run (the default) never needs it. */
|
||||
export const REELS_INGEST_CONFIRM_FLAG = 'REELS_INGEST_CONFIRM'
|
||||
|
||||
const USAGE = 'uso: `pnpm reels:ingest <diretório> [--apply]`'
|
||||
|
||||
/**
|
||||
* Declared environment → exact database name. The name is the honest
|
||||
* discriminator on the homeserver: the socat proxy rewrites the host to
|
||||
* 127.0.0.1 and `NODE_ENV=production` is set in both env files, so
|
||||
* `assertLocalDatabase` alone cannot tell staging from production.
|
||||
*/
|
||||
const REEL_INGEST_DATABASE_BY_ENV = Object.freeze({
|
||||
staging: 'teqo_staging',
|
||||
production: 'teqo_1313',
|
||||
})
|
||||
|
||||
/** Identity hash of the shot list written by the production skill (sha256 hex). */
|
||||
const REEL_INGEST_SHOT_LIST_HASH_RE = /^[a-f0-9]{64}$/
|
||||
|
||||
/**
|
||||
* Fail-closed write gate: `--apply` only proceeds with the explicit intent
|
||||
* flag. Pure and injectable so the unit pin covers the refusal.
|
||||
*
|
||||
* @param {{ apply?: boolean, confirm?: boolean }} [options]
|
||||
*/
|
||||
export const assertReelIngestApplyConfirm = ({
|
||||
apply = false,
|
||||
confirm = isTruthyEnv(process.env[REELS_INGEST_CONFIRM_FLAG]),
|
||||
} = {}) => {
|
||||
if (!apply || confirm) return
|
||||
throw new Error(
|
||||
'modo --apply ESCREVE na biblioteca — exige confirmação explícita de intenção.\n' +
|
||||
` Re-rodar com: ${REELS_INGEST_CONFIRM_FLAG}=1 pnpm reels:ingest <diretório> --apply\n` +
|
||||
' (sem --apply o comando só confere e mostra o que seria criado ou atualizado).',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `pnpm reels:ingest <diretório> [--apply]` argv parser. The directory is the
|
||||
* one positional; `--apply` is the only flag (bare, no value). Errors carry the
|
||||
* usage so the CLI can `die` with them.
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{ directory: string, apply: boolean }}
|
||||
*/
|
||||
export const parseReelIngestArgs = (argv = process.argv.slice(2)) => {
|
||||
const { flags, positional } = parseEqualsFlags(argv)
|
||||
const unknown = Object.keys(flags).filter((flag) => flag !== 'apply')
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`argumento desconhecido: ${unknown.map((flag) => `--${flag}`).join(', ')} — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
if (flags.apply !== undefined && flags.apply !== true) {
|
||||
throw new Error(`--apply não aceita valor — ${USAGE}.`)
|
||||
}
|
||||
if (positional.length === 0) {
|
||||
throw new Error(`informe o diretório do pacote — ${USAGE}.`)
|
||||
}
|
||||
if (positional.length > 1) {
|
||||
throw new Error(
|
||||
`argumento posicional extra: ${positional.slice(1).join(', ')} — o pacote é um único diretório; ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
return { directory: positional[0], apply: flags.apply === true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed target guard. Outside a test run the declared `TEQO_ENV`
|
||||
* (`staging`|`production`) is mandatory and must match the exact database name;
|
||||
* inside vitest only a `teqo*_test` database is accepted. `ALLOW_REMOTE_DB` is
|
||||
* always refused — the ingest never runs from a workstation against a remote
|
||||
* database.
|
||||
*
|
||||
* @param {{
|
||||
* databaseUrl?: string | null,
|
||||
* teqoEnv?: string | null,
|
||||
* allowRemoteDb?: boolean,
|
||||
* isTest?: boolean,
|
||||
* }} [options]
|
||||
* @returns {{ environment: 'staging' | 'production' | 'test', databaseName: string }}
|
||||
*/
|
||||
export const assertReelIngestTarget = ({
|
||||
databaseUrl = process.env.DATABASE_URL,
|
||||
teqoEnv = process.env.TEQO_ENV,
|
||||
allowRemoteDb = isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG]),
|
||||
isTest = process.env.NODE_ENV === 'test' || isTruthyEnv(process.env.VITEST),
|
||||
} = {}) => {
|
||||
if (databaseUrl === undefined || databaseUrl === null || String(databaseUrl).trim() === '') {
|
||||
throw new Error(
|
||||
'DATABASE_URL ausente — rode no ambiente do alvo (env file do stack) para o guard saber onde escrever.',
|
||||
)
|
||||
}
|
||||
|
||||
const targetDatabaseName = databaseName(databaseUrl)
|
||||
|
||||
let protocol = null
|
||||
try {
|
||||
protocol = new URL(String(databaseUrl ?? '')).protocol
|
||||
} catch {
|
||||
protocol = null
|
||||
}
|
||||
if (protocol !== 'postgresql:') {
|
||||
throw new Error(
|
||||
`protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — o ingest só escreve num alvo PostgreSQL.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLocalDatabaseUrl(databaseUrl)) {
|
||||
// Never echo the URL — it carries the DB password.
|
||||
throw new Error(
|
||||
`host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — o ingest nunca escreve em alvo remoto.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (allowRemoteDb) {
|
||||
throw new Error(
|
||||
'ALLOW_REMOTE_DB está setado — remova; o ingest não precisa do override remoto.',
|
||||
)
|
||||
}
|
||||
|
||||
if (isTest) {
|
||||
if (targetDatabaseName === null || !TEST_DATABASE_NAME_RE.test(targetDatabaseName)) {
|
||||
throw new Error(
|
||||
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" não é de teste — o ingest em teste só escreve em "teqo*_test".`,
|
||||
)
|
||||
}
|
||||
return { environment: 'test', databaseName: targetDatabaseName }
|
||||
}
|
||||
|
||||
if (teqoEnv !== 'staging' && teqoEnv !== 'production') {
|
||||
throw new Error(
|
||||
`TEQO_ENV="${teqoEnv ?? '(ausente)'}" — declare o ambiente do alvo: TEQO_ENV=staging ou TEQO_ENV=production.`,
|
||||
)
|
||||
}
|
||||
|
||||
const expected = REEL_INGEST_DATABASE_BY_ENV[teqoEnv]
|
||||
if (targetDatabaseName !== expected) {
|
||||
throw new Error(
|
||||
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${expected}" para TEQO_ENV="${teqoEnv}" — ` +
|
||||
'o comando recusa escrever num alvo que não corresponde ao ambiente declarado.',
|
||||
)
|
||||
}
|
||||
|
||||
return { environment: teqoEnv, databaseName: targetDatabaseName }
|
||||
}
|
||||
|
||||
/**
|
||||
* All reasons a parsed metadata + file listing is not a valid package. Empty
|
||||
* array means valid; the reader joins the list into one error.
|
||||
*
|
||||
* @param {{ metadata: unknown, fileNames: Set<string> }} input
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const reelPackageIssues = ({ metadata, fileNames }) => {
|
||||
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
||||
return [`${REEL_PACKAGE_METADATA_FILENAME} deve conter um objeto JSON.`]
|
||||
}
|
||||
|
||||
const issues = []
|
||||
const keys = REEL_PACKAGE_METADATA_KEYS
|
||||
|
||||
const title = metadata[keys.title]
|
||||
if (typeof title !== 'string' || title.trim() === '') {
|
||||
issues.push(`${keys.title} ausente ou vazio.`)
|
||||
} else if (title.length > REEL_TITLE_MAX_LENGTH) {
|
||||
issues.push(`${keys.title} excede ${REEL_TITLE_MAX_LENGTH} caracteres.`)
|
||||
}
|
||||
|
||||
const feature = metadata[keys.feature]
|
||||
if (!REEL_FEATURES.includes(feature)) {
|
||||
issues.push(
|
||||
`${keys.feature} "${String(feature)}" fora do vocabulário (${REEL_FEATURES.join(', ')}).`,
|
||||
)
|
||||
}
|
||||
|
||||
const shotListHash = metadata[keys.shotListHash]
|
||||
if (typeof shotListHash !== 'string' || !REEL_INGEST_SHOT_LIST_HASH_RE.test(shotListHash)) {
|
||||
issues.push(`${keys.shotListHash} deve ser um sha256 hex (64 caracteres minúsculos).`)
|
||||
}
|
||||
|
||||
const coverAlt = metadata[keys.coverAlt]
|
||||
if (typeof coverAlt !== 'string' || coverAlt.trim() === '') {
|
||||
issues.push(`${keys.coverAlt} ausente ou vazio.`)
|
||||
}
|
||||
|
||||
const durationSeconds = metadata[keys.durationSeconds]
|
||||
if (
|
||||
durationSeconds !== undefined &&
|
||||
durationSeconds !== null &&
|
||||
(typeof durationSeconds !== 'number' ||
|
||||
!Number.isFinite(durationSeconds) ||
|
||||
durationSeconds <= 0)
|
||||
) {
|
||||
issues.push(`${keys.durationSeconds} inválido (segundos positivos).`)
|
||||
}
|
||||
|
||||
const createdAt = metadata[keys.createdAt]
|
||||
if (
|
||||
createdAt !== undefined &&
|
||||
createdAt !== null &&
|
||||
(typeof createdAt !== 'string' || Number.isNaN(Date.parse(createdAt)))
|
||||
) {
|
||||
issues.push(`${keys.createdAt} inválido (data ISO 8601).`)
|
||||
}
|
||||
|
||||
for (const artifact of REEL_PACKAGE_ARTIFACTS) {
|
||||
if (artifact.required && !fileNames.has(artifact.filename)) {
|
||||
issues.push(`artefato obrigatório ausente: ${artifact.filename}.`)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and validates one package directory. Throws with every pending item so
|
||||
* the operator fixes the package in one pass; never touches the database.
|
||||
*
|
||||
* @param {string} directory
|
||||
* @returns {Promise<{
|
||||
* directory: string,
|
||||
* metadata: Record<string, unknown>,
|
||||
* artifacts: Array<{
|
||||
* filename: string,
|
||||
* kind: string,
|
||||
* field: string,
|
||||
* required: boolean,
|
||||
* present: boolean,
|
||||
* path: string,
|
||||
* }>,
|
||||
* transcriptPath: string | null,
|
||||
* }>}
|
||||
*/
|
||||
export const readReelPackage = async (directory) => {
|
||||
const directoryStat = await stat(directory).catch(() => null)
|
||||
if (!directoryStat?.isDirectory()) {
|
||||
throw new Error(`diretório do pacote inválido: ${directory}`)
|
||||
}
|
||||
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name))
|
||||
|
||||
if (!fileNames.has(REEL_PACKAGE_METADATA_FILENAME)) {
|
||||
throw new Error(`pacote incompleto: ${REEL_PACKAGE_METADATA_FILENAME} não encontrado.`)
|
||||
}
|
||||
|
||||
let metadata
|
||||
try {
|
||||
metadata = JSON.parse(await readFile(join(directory, REEL_PACKAGE_METADATA_FILENAME), 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${REEL_PACKAGE_METADATA_FILENAME} ilegível: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const issues = reelPackageIssues({ metadata, fileNames })
|
||||
if (issues.length > 0) {
|
||||
throw new Error(`pacote inválido:\n - ${issues.join('\n - ')}`)
|
||||
}
|
||||
|
||||
const artifacts = REEL_PACKAGE_ARTIFACTS.map((artifact) => ({
|
||||
...artifact,
|
||||
field: reelMediaFieldByKind[artifact.kind],
|
||||
present: fileNames.has(artifact.filename),
|
||||
path: join(directory, artifact.filename),
|
||||
}))
|
||||
|
||||
const emptyArtifacts = []
|
||||
for (const artifact of artifacts) {
|
||||
if (!artifact.present) continue
|
||||
const artifactStat = await stat(artifact.path).catch(() => null)
|
||||
if (artifactStat === null || !artifactStat.isFile() || artifactStat.size === 0) {
|
||||
emptyArtifacts.push(artifact.filename)
|
||||
}
|
||||
}
|
||||
if (emptyArtifacts.length > 0) {
|
||||
throw new Error(
|
||||
`pacote inválido:\n - artefato vazio ou ilegível: ${emptyArtifacts.join(', ')}.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
directory,
|
||||
metadata,
|
||||
artifacts,
|
||||
transcriptPath: fileNames.has(REEL_PACKAGE_TRANSCRIPT_FILENAME)
|
||||
? join(directory, REEL_PACKAGE_TRANSCRIPT_FILENAME)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/playwright-e2e-args.mjs',
|
||||
'scripts/lib/portalTransparenciaEmendas.mjs',
|
||||
'scripts/lib/readOnlyExtract.mjs',
|
||||
'scripts/lib/reel-ingest.mjs',
|
||||
'scripts/lib/reportText.mjs',
|
||||
'scripts/lib/seed-minimal-manifest.mjs',
|
||||
'scripts/lib/sollaCeuciSalvadorMetrics.mjs',
|
||||
|
|
|
|||
135
scripts/reels-ingest.mjs
Normal file
135
scripts/reels-ingest.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* `pnpm reels:ingest <diretório-do-pacote> [--apply]` — C195 ingest of a reel
|
||||
* package into the private library of `/campanha`.
|
||||
*
|
||||
* Runs on the homeserver maintenance image, where the production credentials
|
||||
* already live and the package arrives by rsync: the command writes through the
|
||||
* Payload Local API (never raw SQL, never a campaign credential on the
|
||||
* workstation). Dry-run is the default and validates everything — target,
|
||||
* package, S3 — without writing; `--apply` additionally requires the explicit
|
||||
* intent flag REELS_INGEST_CONFIRM=1 and refuses a database that does not match
|
||||
* the declared TEQO_ENV (fail-closed pair).
|
||||
*
|
||||
* Runbook (staging first, then production): docs/ops/teqo-1313-deploy.md.
|
||||
*/
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { dieWithLabel, loadCliEnv } from './lib/cli.mjs'
|
||||
import {
|
||||
REELS_INGEST_CONFIRM_FLAG,
|
||||
assertReelIngestApplyConfirm,
|
||||
assertReelIngestTarget,
|
||||
parseReelIngestArgs,
|
||||
readReelPackage,
|
||||
} from './lib/reel-ingest.mjs'
|
||||
|
||||
import { REEL_PACKAGE_TRANSCRIPT_FILENAME } from '../src/lib/reel.ts'
|
||||
import { resolveS3StorageEnv } from '../src/utilities/mediaStorage.ts'
|
||||
import {
|
||||
findReelBySourceHash,
|
||||
ingestReelPackage,
|
||||
} from '../src/utilities/reels/reelPackageIngest.ts'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('reels:ingest')
|
||||
|
||||
let args
|
||||
try {
|
||||
args = parseReelIngestArgs()
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
|
||||
try {
|
||||
assertReelIngestApplyConfirm({ apply: args.apply })
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
|
||||
let target
|
||||
try {
|
||||
target = assertReelIngestTarget()
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
|
||||
let storage
|
||||
try {
|
||||
storage = resolveS3StorageEnv(process.env)
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
if (target.environment !== 'test' && !storage.enabled) {
|
||||
die(
|
||||
`sem S3_* completo no alvo ${target.environment} — a ingestão é uma operação do bucket privado de produção.\n` +
|
||||
' O container de manutenção gravaria num disco efêmero; configure as 4 S3_* no env file do ambiente.',
|
||||
)
|
||||
}
|
||||
|
||||
let reelPackage
|
||||
try {
|
||||
reelPackage = await readReelPackage(args.directory)
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
|
||||
const mode = args.apply ? 'apply' : 'dry-run'
|
||||
console.log(
|
||||
[
|
||||
'\n[reels:ingest] Alvo da execução:',
|
||||
` TEQO_ENV : ${target.environment}`,
|
||||
` DB : ${target.databaseName}`,
|
||||
` Bucket : ${storage.enabled ? storage.bucket : '(storage local de teste)'}`,
|
||||
` Modo : ${mode}`,
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const payload = await getPayload({ config: (await import('../src/payload.config.ts')).default })
|
||||
const existing = await findReelBySourceHash(payload, reelPackage.metadata.shotListHash)
|
||||
|
||||
console.log(
|
||||
[
|
||||
`[reels:ingest] Pacote : ${reelPackage.directory}`,
|
||||
` Título : ${reelPackage.metadata.title}`,
|
||||
` Feature : ${reelPackage.metadata.feature}`,
|
||||
` Hash : ${reelPackage.metadata.shotListHash}`,
|
||||
` Operação : ${
|
||||
existing === null
|
||||
? 'cria uma nova entrada (publicada)'
|
||||
: `atualiza o reel #${existing.id} (status atual: ${existing.status})`
|
||||
}`,
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
for (const artifact of reelPackage.artifacts) {
|
||||
// A missing required artifact never reaches the echo: readReelPackage refuses
|
||||
// the package first.
|
||||
const action = artifact.present ? `→ ${artifact.kind}` : 'ausente (opcional)'
|
||||
console.log(`[reels:ingest] ${artifact.filename.padEnd(16)} ${action}`)
|
||||
}
|
||||
console.log(
|
||||
`[reels:ingest] ${REEL_PACKAGE_TRANSCRIPT_FILENAME.padEnd(16)} ${
|
||||
reelPackage.transcriptPath === null ? 'ausente (opcional)' : '→ transcript'
|
||||
}`,
|
||||
)
|
||||
|
||||
if (!args.apply) {
|
||||
console.log(
|
||||
`\n[reels:ingest] dry-run OK — nenhuma escrita. Para aplicar: ${REELS_INGEST_CONFIRM_FLAG}=1 pnpm reels:ingest ${args.directory} --apply`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await ingestReelPackage(payload, reelPackage)
|
||||
} catch (error) {
|
||||
die(
|
||||
`a escrita falhou (nada parcial ficou visível — a transação foi revertida): ${error?.message ?? String(error)}`,
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`\n[reels:ingest] ${result.operation === 'created' ? 'criado' : 'atualizado'} o reel #${result.reelId} — a biblioteca já reflete o pacote.`,
|
||||
)
|
||||
process.exit(0)
|
||||
|
|
@ -98,6 +98,21 @@ export const Reel: CollectionConfig = {
|
|||
description: 'Funcionalidade do site que o tutorial ensina.',
|
||||
},
|
||||
},
|
||||
{
|
||||
// C195 identity: re-ingesting the same package (same shot list hash)
|
||||
// updates this reel instead of creating a duplicate. Nullable so reels
|
||||
// registered by hand in the admin have no hash; unique so the database
|
||||
// itself refuses two reels with the same package.
|
||||
name: 'sourceHash',
|
||||
type: 'text',
|
||||
label: 'Hash do shot list',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Hash do shot list do pacote; identifica o reel na ingestão (C195).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
|
|
|
|||
110
src/lib/reel.ts
110
src/lib/reel.ts
|
|
@ -2,7 +2,9 @@
|
|||
* 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.
|
||||
* authenticated media route and the C194 library share this module. Since C195
|
||||
* it also owns the ingestion package contract (`REEL_PACKAGE_*`) that the
|
||||
* production skill (C196/C197) writes and `pnpm reels:ingest` validates.
|
||||
*/
|
||||
|
||||
/** Upload collection of the artifacts; also its local disk directory in dev. */
|
||||
|
|
@ -64,3 +66,109 @@ export const reelMediaPath = (reelId: number, kind: ReelMediaKind): string =>
|
|||
|
||||
/** Only a published reel serves its files; `draft`/`unpublished` answer 404. */
|
||||
export const canServeReelMedia = (status: ReelStatus): boolean => status === 'published'
|
||||
|
||||
/** Manifest file of the ingestion package (C195). */
|
||||
export const REEL_PACKAGE_METADATA_FILENAME = 'metadata.json'
|
||||
|
||||
/** Human-readable script of the package; becomes the reel `transcript`. */
|
||||
export const REEL_PACKAGE_TRANSCRIPT_FILENAME = 'roteiro.md'
|
||||
|
||||
/**
|
||||
* Keys of `metadata.json`. `shotListHash` is the identity of the reel (the
|
||||
* ingest updates the same entry for the same hash); `coverAlt` is the required
|
||||
* alt text of the cover. `durationSeconds`/`createdAt` are validated when
|
||||
* present but deliberately not persisted (no consumer yet).
|
||||
*/
|
||||
export const REEL_PACKAGE_METADATA_KEYS = {
|
||||
title: 'title',
|
||||
feature: 'feature',
|
||||
shotListHash: 'shotListHash',
|
||||
coverAlt: 'coverAlt',
|
||||
durationSeconds: 'durationSeconds',
|
||||
createdAt: 'createdAt',
|
||||
} as const
|
||||
|
||||
export type ReelPackageArtifact = {
|
||||
/** File name inside the package directory. */
|
||||
filename: string
|
||||
kind: ReelMediaKind
|
||||
/** Required artifacts must be present; optional ones are ingested when there. */
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/** The package files, in the order the ingest echoes them. */
|
||||
export const REEL_PACKAGE_ARTIFACTS: readonly ReelPackageArtifact[] = [
|
||||
{ filename: 'reel.mp4', kind: 'video', required: true },
|
||||
{ filename: 'capa.png', kind: 'cover', required: true },
|
||||
{ filename: 'narracao.srt', kind: 'captions', required: true },
|
||||
{ filename: 'reel-audio.mp4', kind: 'video-audio', required: false },
|
||||
{ filename: 'narracao.mp3', kind: 'narration', required: false },
|
||||
]
|
||||
|
||||
/** Content type per artifact kind — the package filenames are fixed, so is this. */
|
||||
const REEL_PACKAGE_MIMETYPES: Record<ReelMediaKind, string> = {
|
||||
video: 'video/mp4',
|
||||
'video-audio': 'video/mp4',
|
||||
narration: 'audio/mpeg',
|
||||
captions: 'application/x-subrip',
|
||||
cover: 'image/png',
|
||||
}
|
||||
|
||||
export const reelArtifactMimetype = (kind: ReelMediaKind): string => REEL_PACKAGE_MIMETYPES[kind]
|
||||
|
||||
/**
|
||||
* Deterministic stored file name: re-ingesting the same reel (same shot list
|
||||
* hash) overwrites the same object key, and the full hash makes two reels
|
||||
* collide only if their shot lists do.
|
||||
*/
|
||||
export const reelArtifactStorageFilename = (
|
||||
shotListHash: string,
|
||||
artifact: Pick<ReelPackageArtifact, 'filename' | 'kind'>,
|
||||
): string => {
|
||||
const extension = artifact.filename.match(/\.[^.]+$/)?.[0] ?? ''
|
||||
return `reel-${shotListHash}-${artifact.kind}${extension}`
|
||||
}
|
||||
|
||||
/** One artifact entry of a read package: contract + where it lives on disk. */
|
||||
export type ReelPackageArtifactEntry = ReelPackageArtifact & {
|
||||
field: ReelMediaField
|
||||
present: boolean
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape `readReelPackage` returns and `ingestReelPackage` consumes. Lives
|
||||
* next to the package contract because it is the same contract: the producer
|
||||
* writes these files, the CLI validates them and the writer persists them.
|
||||
*/
|
||||
export type ReelPackage = {
|
||||
directory: string
|
||||
metadata: {
|
||||
title: string
|
||||
feature: ReelFeature
|
||||
shotListHash: string
|
||||
coverAlt: string
|
||||
}
|
||||
artifacts: ReelPackageArtifactEntry[]
|
||||
transcriptPath: string | null
|
||||
}
|
||||
|
||||
/** Alt text: the manifest alt for the cover, derived from the title otherwise. */
|
||||
export const reelArtifactAlt = ({
|
||||
title,
|
||||
kind,
|
||||
coverAlt,
|
||||
}: {
|
||||
title: string
|
||||
kind: ReelMediaKind
|
||||
coverAlt: string
|
||||
}): string => {
|
||||
if (kind === 'cover') return coverAlt
|
||||
const labels: Record<Exclude<ReelMediaKind, 'cover'>, string> = {
|
||||
video: 'Vídeo',
|
||||
'video-audio': 'Vídeo com áudio',
|
||||
narration: 'Narração',
|
||||
captions: 'Legendas',
|
||||
}
|
||||
return `${labels[kind]} do reel "${title}"`
|
||||
}
|
||||
|
|
|
|||
15984
src/migrations/20260919_002605_add_reel_source_hash.json
Normal file
15984
src/migrations/20260919_002605_add_reel_source_hash.json
Normal file
File diff suppressed because it is too large
Load diff
13
src/migrations/20260919_002605_add_reel_source_hash.ts
Normal file
13
src/migrations/20260919_002605_add_reel_source_hash.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "reel" ADD COLUMN "source_hash" varchar;
|
||||
CREATE UNIQUE INDEX "reel_source_hash_idx" ON "reel" USING btree ("source_hash");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP INDEX "reel_source_hash_idx";
|
||||
ALTER TABLE "reel" DROP COLUMN "source_hash";`)
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ import * as migration_20260913_150000_backfill_speech_search_text_trgm_index fro
|
|||
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_20260918_201009_add_reel from './20260918_201009_add_reel';
|
||||
import * as migration_20260919_002605_add_reel_source_hash from './20260919_002605_add_reel_source_hash';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -448,6 +449,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260918_201009_add_reel.up,
|
||||
down: migration_20260918_201009_add_reel.down,
|
||||
name: '20260918_201009_add_reel'
|
||||
name: '20260918_201009_add_reel',
|
||||
},
|
||||
{
|
||||
up: migration_20260919_002605_add_reel_source_hash.up,
|
||||
down: migration_20260919_002605_add_reel_source_hash.down,
|
||||
name: '20260919_002605_add_reel_source_hash'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,10 @@ export interface Reel {
|
|||
* Funcionalidade do site que o tutorial ensina.
|
||||
*/
|
||||
feature: 'cards';
|
||||
/**
|
||||
* Hash do shot list do pacote; identifica o reel na ingestão (C195).
|
||||
*/
|
||||
sourceHash?: string | null;
|
||||
/**
|
||||
* Despublicar tira o reel da biblioteca e para de servir os arquivos (kill switch).
|
||||
*/
|
||||
|
|
@ -2280,6 +2284,7 @@ export interface SpeechCutSelect<T extends boolean = true> {
|
|||
export interface ReelSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
feature?: T;
|
||||
sourceHash?: T;
|
||||
status?: T;
|
||||
video?: T;
|
||||
videoWithAudio?: T;
|
||||
|
|
|
|||
186
src/utilities/reels/reelPackageIngest.ts
Normal file
186
src/utilities/reels/reelPackageIngest.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import 'server-only'
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
reelArtifactAlt,
|
||||
reelArtifactMimetype,
|
||||
reelArtifactStorageFilename,
|
||||
type ReelMediaField,
|
||||
type ReelPackage,
|
||||
type ReelPackageArtifactEntry,
|
||||
} from '@/lib/reel'
|
||||
import type { Reel, ReelMedia } from '@/payload-types'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
/**
|
||||
* C195 — writes one validated reel package into the private library through the
|
||||
* Local API. Runs on the homeserver maintenance image (`pnpm reels:ingest`),
|
||||
* so it deliberately has no `user`: the CLI is an admin-level operation (same
|
||||
* precedent as `seed-minimal`/staging test account), `createdBy` stays null and
|
||||
* every database write shares one transaction — a failure never leaves a
|
||||
* partially visible reel. Upload bytes live outside the transaction;
|
||||
* deterministic object names make a re-run overwrite the same keys (an orphan
|
||||
* object after a rollback is accepted and self-heals on the next run).
|
||||
*
|
||||
* Idempotency: the `sourceHash` (shot list hash of the manifest) identifies the
|
||||
* reel. A package with a known hash updates that entry in place — the same
|
||||
* `reelMedia` documents, never duplicates — and preserves the current `status`,
|
||||
* so re-ingesting never resurrects an unpublished reel. A new hash publishes a
|
||||
* new entry.
|
||||
*/
|
||||
|
||||
export type ReelIngestResult = {
|
||||
operation: 'created' | 'updated'
|
||||
reelId: number
|
||||
}
|
||||
|
||||
type ReelWriteData = {
|
||||
title: string
|
||||
feature: Reel['feature']
|
||||
sourceHash: string
|
||||
video: number
|
||||
cover: number
|
||||
transcript?: string
|
||||
} & Partial<Record<'videoWithAudio' | 'narrationAudio' | 'captions', number>>
|
||||
|
||||
const mediaIdOf = (value: Reel[ReelMediaField]): number | null => {
|
||||
if (value === null || value === undefined) return null
|
||||
return typeof value === 'number' ? value : value.id
|
||||
}
|
||||
|
||||
/**
|
||||
* The reel registered by this package, or null when the hash is new. Reads
|
||||
* without a campaign user — the documented CLI admin bypass (same precedent as
|
||||
* seed-minimal/staging test account).
|
||||
*/
|
||||
export const findReelBySourceHash = async (
|
||||
payload: Payload,
|
||||
sourceHash: string,
|
||||
): Promise<Reel | null> => {
|
||||
// Documented admin bypass: the CLI has no campaign user.
|
||||
const found = await payload.find({
|
||||
collection: 'reel',
|
||||
where: { sourceHash: { equals: sourceHash } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return found.docs[0] ?? null
|
||||
}
|
||||
|
||||
const artifactUpload = async (artifact: ReelPackageArtifactEntry, shotListHash: string) => {
|
||||
const data = await readFile(artifact.path)
|
||||
return {
|
||||
data,
|
||||
mimetype: reelArtifactMimetype(artifact.kind),
|
||||
name: reelArtifactStorageFilename(shotListHash, artifact),
|
||||
size: data.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the reel of the package. Required artifacts are validated
|
||||
* by `readReelPackage` before this runs; optional artifacts absent from the
|
||||
* package leave the previous value untouched (a re-render without the draft
|
||||
* audio never silently erases it). The transcript follows the same rule.
|
||||
*/
|
||||
export const ingestReelPackage = async (
|
||||
payload: Payload,
|
||||
reelPackage: ReelPackage,
|
||||
): Promise<ReelIngestResult> => {
|
||||
const { metadata, artifacts, transcriptPath } = reelPackage
|
||||
const transcript = transcriptPath === null ? null : await readFile(transcriptPath, 'utf8')
|
||||
const existing = await findReelBySourceHash(payload, metadata.shotListHash)
|
||||
|
||||
return withPayloadTransaction(payload, async ({ req }) => {
|
||||
const mediaIds: Partial<Record<ReelMediaField, number>> = {}
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
if (!artifact.present) continue
|
||||
|
||||
const alt = reelArtifactAlt({
|
||||
title: metadata.title,
|
||||
kind: artifact.kind,
|
||||
coverAlt: metadata.coverAlt,
|
||||
})
|
||||
const existingMediaId = existing === null ? null : mediaIdOf(existing[artifact.field])
|
||||
|
||||
// No campaign user in the CLI: the documented admin bypass for reelMedia.
|
||||
let media: ReelMedia
|
||||
if (existingMediaId === null) {
|
||||
media = await payload.create({
|
||||
collection: 'reelMedia',
|
||||
data: { alt },
|
||||
file: await artifactUpload(artifact, metadata.shotListHash),
|
||||
// Documented admin bypass. Deterministic name: overwrite the
|
||||
// previous object instead of letting Payload suffix the filename on
|
||||
// every re-ingest.
|
||||
overwriteExistingFiles: true,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
} else {
|
||||
media = await payload.update({
|
||||
collection: 'reelMedia',
|
||||
id: existingMediaId,
|
||||
data: { alt },
|
||||
file: await artifactUpload(artifact, metadata.shotListHash),
|
||||
overwriteExistingFiles: true,
|
||||
depth: 0,
|
||||
// Same documented bypass on the update path.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
mediaIds[artifact.field] = media.id
|
||||
}
|
||||
|
||||
// The reader already required video + cover; the ids come from the existing
|
||||
// reel or the freshly created media, so the create data is complete.
|
||||
const requiredMediaId = (field: 'video' | 'cover'): number => {
|
||||
const id = mediaIds[field] ?? (existing === null ? null : mediaIdOf(existing[field]))
|
||||
if (id === null || id === undefined) {
|
||||
throw new Error(`artefato obrigatório sem mídia: ${field}.`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
const data: ReelWriteData = {
|
||||
title: metadata.title,
|
||||
feature: metadata.feature,
|
||||
sourceHash: metadata.shotListHash,
|
||||
video: requiredMediaId('video'),
|
||||
cover: requiredMediaId('cover'),
|
||||
...(transcript === null ? {} : { transcript }),
|
||||
...(mediaIds.videoWithAudio === undefined ? {} : { videoWithAudio: mediaIds.videoWithAudio }),
|
||||
...(mediaIds.narrationAudio === undefined ? {} : { narrationAudio: mediaIds.narrationAudio }),
|
||||
...(mediaIds.captions === undefined ? {} : { captions: mediaIds.captions }),
|
||||
}
|
||||
|
||||
if (existing !== null) {
|
||||
// No campaign user in the CLI: the documented admin bypass for the reel.
|
||||
const updated = await payload.update({
|
||||
collection: 'reel',
|
||||
id: existing.id,
|
||||
data,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return { operation: 'updated', reelId: updated.id }
|
||||
}
|
||||
|
||||
// No campaign user in the CLI: the documented admin bypass for the reel.
|
||||
const created = await payload.create({
|
||||
collection: 'reel',
|
||||
data: { ...data, status: 'published' },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
return { operation: 'created', reelId: created.id }
|
||||
})
|
||||
}
|
||||
BIN
tests/fixtures/reel-package/capa.png
vendored
Normal file
BIN
tests/fixtures/reel-package/capa.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
8
tests/fixtures/reel-package/metadata.json
vendored
Normal file
8
tests/fixtures/reel-package/metadata.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"title": "Tutorial dos cards de apoio",
|
||||
"feature": "cards",
|
||||
"shotListHash": "8f900e80b7e4628ea39050031436b25ea337e15924243e21676ea7b25bfeefc3",
|
||||
"coverAlt": "Tela inicial dos cards de apoio do site",
|
||||
"durationSeconds": 38,
|
||||
"createdAt": "2026-09-18T18:00:00.000Z"
|
||||
}
|
||||
3
tests/fixtures/reel-package/narracao.srt
vendored
Normal file
3
tests/fixtures/reel-package/narracao.srt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
1
|
||||
00:00:00,000 --> 00:00:02,000
|
||||
Bem-vindo aos cards
|
||||
BIN
tests/fixtures/reel-package/reel.mp4
vendored
Normal file
BIN
tests/fixtures/reel-package/reel.mp4
vendored
Normal file
Binary file not shown.
3
tests/fixtures/reel-package/roteiro.md
vendored
Normal file
3
tests/fixtures/reel-package/roteiro.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Roteiro do tutorial dos cards
|
||||
|
||||
Aponte para os cards de apoio na home.
|
||||
190
tests/int/reelIngest.int.spec.ts
Normal file
190
tests/int/reelIngest.int.spec.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { copyFile, cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: () => undefined,
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
import type { ReelPackage } from '@/lib/reel'
|
||||
import type { Reel } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import { findReelBySourceHash, ingestReelPackage } from '@/utilities/reels/reelPackageIngest'
|
||||
import { readReelPackage } from '../../scripts/lib/reel-ingest.mjs'
|
||||
|
||||
const FIXTURE_DIR = join(process.cwd(), 'tests/fixtures/reel-package')
|
||||
const FIXTURE_HASH = '8f900e80b7e4628ea39050031436b25ea337e15924243e21676ea7b25bfeefc3'
|
||||
const MEDIA_FIELDS = ['video', 'videoWithAudio', 'narrationAudio', 'captions', 'cover'] as const
|
||||
|
||||
let payload: Payload
|
||||
const createdReelIds = new Set<number>()
|
||||
const createdMediaIds = new Set<number>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
const trackReel = (reel: Reel): Reel => {
|
||||
createdReelIds.add(reel.id)
|
||||
for (const field of MEDIA_FIELDS) {
|
||||
const value = reel[field]
|
||||
if (typeof value === 'number') createdMediaIds.add(value)
|
||||
else if (typeof value === 'object' && value !== null) createdMediaIds.add(value.id)
|
||||
}
|
||||
return reel
|
||||
}
|
||||
|
||||
const tempFixture = async (mutate?: (directory: string) => Promise<void>) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'reel-package-int-'))
|
||||
tempDirs.push(directory)
|
||||
await cp(FIXTURE_DIR, directory, { recursive: true })
|
||||
if (mutate) await mutate(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
/** The CLI passes the reader's validated output straight to the writer. */
|
||||
const readPackage = (directory: string) => readReelPackage(directory) as Promise<ReelPackage>
|
||||
|
||||
describe('reel package ingest (C195)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('creates one published reel with every artifact linked to the same entry', async () => {
|
||||
const reelPackage = await readPackage(FIXTURE_DIR)
|
||||
const result = await ingestReelPackage(payload, reelPackage)
|
||||
|
||||
expect(result.operation).toBe('created')
|
||||
const reel = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
|
||||
|
||||
expect(reel.id).toBe(result.reelId)
|
||||
expect(reel.status).toBe('published')
|
||||
expect(reel.publishedAt).toBeTruthy()
|
||||
expect(reel.title).toBe('Tutorial dos cards de apoio')
|
||||
expect(reel.feature).toBe('cards')
|
||||
expect(reel.sourceHash).toBe(FIXTURE_HASH)
|
||||
expect(reel.transcript).toContain('Roteiro do tutorial dos cards')
|
||||
for (const field of ['video', 'cover', 'captions'] as const) {
|
||||
expect(typeof reel[field], field).toBe('number')
|
||||
}
|
||||
for (const field of ['videoWithAudio', 'narrationAudio'] as const) {
|
||||
expect(reel[field], field).toBeNull()
|
||||
}
|
||||
|
||||
const video = await payload.findByID({
|
||||
collection: 'reelMedia',
|
||||
id: reel.video as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(video.filename).toBe(`reel-${FIXTURE_HASH}-video.mp4`)
|
||||
expect(video.mimeType).toBe('video/mp4')
|
||||
expect(video.alt).toBe('Vídeo do reel "Tutorial dos cards de apoio"')
|
||||
|
||||
const cover = await payload.findByID({
|
||||
collection: 'reelMedia',
|
||||
id: reel.cover as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(cover.alt).toBe('Tela inicial dos cards de apoio do site')
|
||||
|
||||
const captions = await payload.findByID({
|
||||
collection: 'reelMedia',
|
||||
id: reel.captions as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(captions.mimeType).toBe('application/x-subrip')
|
||||
})
|
||||
|
||||
it('re-ingests the same hash into the same reel and media, never a duplicate', async () => {
|
||||
const first = await readPackage(FIXTURE_DIR)
|
||||
const firstResult = await ingestReelPackage(payload, first)
|
||||
const before = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
|
||||
|
||||
const second = await readPackage(FIXTURE_DIR)
|
||||
const secondResult = await ingestReelPackage(payload, second)
|
||||
const after = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
|
||||
|
||||
expect(secondResult.operation).toBe('updated')
|
||||
expect(secondResult.reelId).toBe(firstResult.reelId)
|
||||
expect(after.id).toBe(before.id)
|
||||
expect(after.video).toBe(before.video)
|
||||
expect(after.cover).toBe(before.cover)
|
||||
expect(after.captions).toBe(before.captions)
|
||||
|
||||
const matches = await payload.find({
|
||||
collection: 'reel',
|
||||
where: { sourceHash: { equals: FIXTURE_HASH } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(matches.totalDocs).toBe(1)
|
||||
})
|
||||
|
||||
it('never resurrects an unpublished reel on re-ingest', async () => {
|
||||
const first = await readPackage(FIXTURE_DIR)
|
||||
const { reelId } = await ingestReelPackage(payload, first)
|
||||
await payload.update({
|
||||
collection: 'reel',
|
||||
id: reelId,
|
||||
data: { status: 'unpublished' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const second = await readPackage(FIXTURE_DIR)
|
||||
await ingestReelPackage(payload, second)
|
||||
|
||||
const reel = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
|
||||
expect(reel.status).toBe('unpublished')
|
||||
expect(reel.title).toBe('Tutorial dos cards de apoio')
|
||||
})
|
||||
|
||||
it('keeps an optional artifact that a later package omitted', async () => {
|
||||
const withAudio = await tempFixture(async (directory) => {
|
||||
await copyFile(join(FIXTURE_DIR, 'reel.mp4'), join(directory, 'reel-audio.mp4'))
|
||||
})
|
||||
await ingestReelPackage(payload, await readPackage(withAudio))
|
||||
|
||||
const before = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
|
||||
expect(typeof before.videoWithAudio).toBe('number')
|
||||
|
||||
await ingestReelPackage(payload, await readPackage(FIXTURE_DIR))
|
||||
|
||||
const after = trackReel((await findReelBySourceHash(payload, FIXTURE_HASH))!)
|
||||
expect(after.videoWithAudio).toBe(before.videoWithAudio)
|
||||
})
|
||||
|
||||
it('refuses an incomplete package before any database write', async () => {
|
||||
const otherHash = 'b'.repeat(64)
|
||||
const directory = await tempFixture(async (dir) => {
|
||||
await rm(join(dir, 'narracao.srt'))
|
||||
const metadata = JSON.parse(await readFile(join(dir, 'metadata.json'), 'utf8'))
|
||||
await writeFile(
|
||||
join(dir, 'metadata.json'),
|
||||
JSON.stringify({ ...metadata, shotListHash: otherHash }),
|
||||
)
|
||||
})
|
||||
|
||||
await expect(readReelPackage(directory)).rejects.toThrow(/narracao\.srt/)
|
||||
expect(await findReelBySourceHash(payload, otherHash)).toBeNull()
|
||||
})
|
||||
})
|
||||
306
tests/unit/reelIngest.unit.spec.ts
Normal file
306
tests/unit/reelIngest.unit.spec.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { reelArtifactAlt, reelArtifactMimetype, reelArtifactStorageFilename } from '@/lib/reel'
|
||||
import {
|
||||
assertReelIngestApplyConfirm,
|
||||
assertReelIngestTarget,
|
||||
parseReelIngestArgs,
|
||||
readReelPackage,
|
||||
reelPackageIssues,
|
||||
} from '../../scripts/lib/reel-ingest.mjs'
|
||||
|
||||
const PNG_BYTES = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
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 validMetadata = (overrides: Record<string, unknown> = {}) => ({
|
||||
title: 'Tutorial dos cards',
|
||||
feature: 'cards',
|
||||
shotListHash: 'a'.repeat(64),
|
||||
coverAlt: 'Tela dos cards de apoio da home',
|
||||
durationSeconds: 42,
|
||||
createdAt: '2026-09-18T12:00:00.000Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
const makePackage = async ({
|
||||
metadata = validMetadata(),
|
||||
files = { 'reel.mp4': MP4_BYTES, 'capa.png': PNG_BYTES, 'narracao.srt': SRT_BYTES },
|
||||
omitMetadata = false,
|
||||
}: {
|
||||
metadata?: unknown
|
||||
files?: Record<string, Buffer>
|
||||
omitMetadata?: boolean
|
||||
} = {}) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'reel-package-'))
|
||||
tempDirs.push(directory)
|
||||
for (const [name, body] of Object.entries(files)) {
|
||||
await writeFile(join(directory, name), body)
|
||||
}
|
||||
if (!omitMetadata) {
|
||||
await writeFile(
|
||||
join(directory, 'metadata.json'),
|
||||
typeof metadata === 'string' ? metadata : JSON.stringify(metadata),
|
||||
)
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('parseReelIngestArgs (C195)', () => {
|
||||
it('reads the package directory and defaults to dry-run', () => {
|
||||
expect(parseReelIngestArgs(['/srv/reels/cards'])).toEqual({
|
||||
directory: '/srv/reels/cards',
|
||||
apply: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the bare --apply write flag', () => {
|
||||
expect(parseReelIngestArgs(['/srv/reels/cards', '--apply'])).toEqual({
|
||||
directory: '/srv/reels/cards',
|
||||
apply: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a missing directory, extra positionals, unknown flags and valued --apply', () => {
|
||||
expect(() => parseReelIngestArgs([])).toThrow(/diretório do pacote/)
|
||||
expect(() => parseReelIngestArgs(['a', 'b'])).toThrow(/posicional extra/)
|
||||
expect(() => parseReelIngestArgs(['/pkg', '--force'])).toThrow(/desconhecido/)
|
||||
expect(() => parseReelIngestArgs(['/pkg', '--apply=1'])).toThrow(/não aceita valor/)
|
||||
for (const argv of [[], ['a', 'b'], ['/pkg', '--force'], ['/pkg', '--apply=1']]) {
|
||||
expect(() => parseReelIngestArgs(argv)).toThrow(/pnpm reels:ingest/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertReelIngestApplyConfirm (C195 write gate)', () => {
|
||||
it('requires the intent flag only on --apply', () => {
|
||||
expect(() => assertReelIngestApplyConfirm({ apply: false, confirm: false })).not.toThrow()
|
||||
expect(() => assertReelIngestApplyConfirm({ apply: true, confirm: true })).not.toThrow()
|
||||
expect(() => assertReelIngestApplyConfirm({ apply: true, confirm: false })).toThrow(
|
||||
/REELS_INGEST_CONFIRM=1/,
|
||||
)
|
||||
expect(() => assertReelIngestApplyConfirm()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertReelIngestTarget (C195 fail-closed)', () => {
|
||||
it('accepts the declared environment paired with its exact database', () => {
|
||||
expect(
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
|
||||
teqoEnv: 'production',
|
||||
isTest: false,
|
||||
}),
|
||||
).toEqual({ environment: 'production', databaseName: 'teqo_1313' })
|
||||
expect(
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@postgres:5432/teqo_staging',
|
||||
teqoEnv: 'staging',
|
||||
isTest: false,
|
||||
}),
|
||||
).toEqual({ environment: 'staging', databaseName: 'teqo_staging' })
|
||||
})
|
||||
|
||||
it('refuses a database that does not match the declared environment', () => {
|
||||
expect(() =>
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_staging',
|
||||
teqoEnv: 'production',
|
||||
isTest: false,
|
||||
}),
|
||||
).toThrow(/≠ "teqo_1313"/)
|
||||
})
|
||||
|
||||
it('refuses a missing or unknown TEQO_ENV', () => {
|
||||
expect(() =>
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
|
||||
teqoEnv: undefined,
|
||||
isTest: false,
|
||||
}),
|
||||
).toThrow(/TEQO_ENV/)
|
||||
expect(() =>
|
||||
assertReelIngestTarget({ databaseUrl: '', teqoEnv: 'production', isTest: false }),
|
||||
).toThrow(/DATABASE_URL ausente/)
|
||||
expect(() =>
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo',
|
||||
teqoEnv: 'dev',
|
||||
isTest: false,
|
||||
}),
|
||||
).toThrow(/TEQO_ENV/)
|
||||
})
|
||||
|
||||
it('refuses the remote override, a remote host and a non-Postgres protocol', () => {
|
||||
expect(() =>
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
|
||||
teqoEnv: 'production',
|
||||
allowRemoteDb: true,
|
||||
isTest: false,
|
||||
}),
|
||||
).toThrow(/ALLOW_REMOTE_DB/)
|
||||
expect(() =>
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@db.example.com:5432/teqo_1313',
|
||||
teqoEnv: 'production',
|
||||
isTest: false,
|
||||
}),
|
||||
).toThrow(/allowlist local/)
|
||||
expect(() =>
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'mysql://teqo:teqo@127.0.0.1:3306/teqo_1313',
|
||||
teqoEnv: 'production',
|
||||
isTest: false,
|
||||
}),
|
||||
).toThrow(/postgresql/)
|
||||
})
|
||||
|
||||
it('accepts only teqo*_test databases inside a test run', () => {
|
||||
expect(
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_wt195_test',
|
||||
isTest: true,
|
||||
}),
|
||||
).toEqual({ environment: 'test', databaseName: 'teqo_wt195_test' })
|
||||
expect(() =>
|
||||
assertReelIngestTarget({
|
||||
databaseUrl: 'postgresql://teqo:teqo@localhost:5432/teqo_1313',
|
||||
isTest: true,
|
||||
}),
|
||||
).toThrow(/não é de teste/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readReelPackage (C195 package contract)', () => {
|
||||
it('reads a complete package and flags the present artifacts', async () => {
|
||||
const directory = await makePackage()
|
||||
|
||||
const reelPackage = await readReelPackage(directory)
|
||||
|
||||
expect(reelPackage.metadata.title).toBe('Tutorial dos cards')
|
||||
expect(reelPackage.transcriptPath).toBeNull()
|
||||
const byKind = Object.fromEntries(reelPackage.artifacts.map((a) => [a.kind, a]))
|
||||
expect(byKind.video).toMatchObject({ field: 'video', required: true, present: true })
|
||||
expect(byKind.cover).toMatchObject({ field: 'cover', required: true, present: true })
|
||||
expect(byKind.captions).toMatchObject({ field: 'captions', required: true, present: true })
|
||||
expect(byKind.narration).toMatchObject({
|
||||
field: 'narrationAudio',
|
||||
required: false,
|
||||
present: false,
|
||||
})
|
||||
expect(byKind['video-audio']).toMatchObject({ field: 'videoWithAudio', present: false })
|
||||
})
|
||||
|
||||
it('reads the optional transcript when the package carries it', async () => {
|
||||
const directory = await makePackage({
|
||||
files: {
|
||||
'reel.mp4': MP4_BYTES,
|
||||
'capa.png': PNG_BYTES,
|
||||
'narracao.srt': SRT_BYTES,
|
||||
'roteiro.md': Buffer.from('# Roteiro'),
|
||||
},
|
||||
})
|
||||
|
||||
const reelPackage = await readReelPackage(directory)
|
||||
|
||||
expect(reelPackage.transcriptPath).toBe(join(directory, 'roteiro.md'))
|
||||
})
|
||||
|
||||
it('refuses an incomplete or invalid package listing every pending item', async () => {
|
||||
const directory = await makePackage({
|
||||
metadata: validMetadata({ feature: 'inventada', shotListHash: 'abc', coverAlt: '' }),
|
||||
files: { 'reel.mp4': MP4_BYTES, 'capa.png': PNG_BYTES },
|
||||
})
|
||||
|
||||
await expect(readReelPackage(directory)).rejects.toThrow(
|
||||
/feature[\s\S]*shotListHash[\s\S]*coverAlt[\s\S]*narracao\.srt/,
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a missing metadata.json, an unreadable one and an unknown directory', async () => {
|
||||
const noMetadata = await makePackage({ omitMetadata: true })
|
||||
await expect(readReelPackage(noMetadata)).rejects.toThrow(/metadata\.json/)
|
||||
|
||||
const brokenMetadata = await makePackage({ metadata: '{ not json' })
|
||||
await expect(readReelPackage(brokenMetadata)).rejects.toThrow(/ilegível/)
|
||||
|
||||
await expect(readReelPackage('/nonexistent/reel-package')).rejects.toThrow(
|
||||
/diretório do pacote inválido/,
|
||||
)
|
||||
})
|
||||
|
||||
it('validates the optional duration and date when present', async () => {
|
||||
const directory = await makePackage({
|
||||
metadata: validMetadata({ durationSeconds: -1, createdAt: 'ontem' }),
|
||||
})
|
||||
|
||||
await expect(readReelPackage(directory)).rejects.toThrow(/durationSeconds[\s\S]*createdAt/)
|
||||
})
|
||||
|
||||
it('refuses an empty artifact before the dry-run says OK', async () => {
|
||||
const directory = await makePackage({
|
||||
files: { 'reel.mp4': Buffer.alloc(0), 'capa.png': PNG_BYTES, 'narracao.srt': SRT_BYTES },
|
||||
})
|
||||
|
||||
await expect(readReelPackage(directory)).rejects.toThrow(
|
||||
/artefato vazio ou ilegível: reel\.mp4/,
|
||||
)
|
||||
})
|
||||
|
||||
it('collects issues without a filesystem through reelPackageIssues', () => {
|
||||
const issues = reelPackageIssues({
|
||||
metadata: validMetadata({ title: 'x'.repeat(201) }),
|
||||
fileNames: new Set(['metadata.json', 'reel.mp4', 'capa.png', 'narracao.srt']),
|
||||
})
|
||||
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0]).toMatch(/title/)
|
||||
expect(reelPackageIssues({ metadata: 42, fileNames: new Set() })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifact naming, mimetype and alt (C195)', () => {
|
||||
it('names stored files deterministically by hash prefix, kind and extension', () => {
|
||||
const hash = '0123456789abcdef'.repeat(4)
|
||||
const video = { filename: 'reel.mp4', kind: 'video' } as const
|
||||
const captions = { filename: 'narracao.srt', kind: 'captions' } as const
|
||||
|
||||
expect(reelArtifactStorageFilename(hash, video)).toBe(`reel-${hash}-video.mp4`)
|
||||
expect(reelArtifactStorageFilename(hash, captions)).toBe(`reel-${hash}-captions.srt`)
|
||||
expect(reelArtifactStorageFilename(hash, video)).toBe(reelArtifactStorageFilename(hash, video))
|
||||
})
|
||||
|
||||
it('maps the fixed content types of the contract', () => {
|
||||
expect(reelArtifactMimetype('video')).toBe('video/mp4')
|
||||
expect(reelArtifactMimetype('video-audio')).toBe('video/mp4')
|
||||
expect(reelArtifactMimetype('narration')).toBe('audio/mpeg')
|
||||
expect(reelArtifactMimetype('captions')).toBe('application/x-subrip')
|
||||
expect(reelArtifactMimetype('cover')).toBe('image/png')
|
||||
})
|
||||
|
||||
it('uses the manifest alt for the cover and derives the others from the title', () => {
|
||||
const input = { title: 'Tutorial dos cards', coverAlt: 'Tela dos cards' }
|
||||
|
||||
expect(reelArtifactAlt({ ...input, kind: 'cover' })).toBe('Tela dos cards')
|
||||
expect(reelArtifactAlt({ ...input, kind: 'video' })).toBe('Vídeo do reel "Tutorial dos cards"')
|
||||
expect(reelArtifactAlt({ ...input, kind: 'narration' })).toBe(
|
||||
'Narração do reel "Tutorial dos cards"',
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue