From 51bd4700bb0c8779c045d5f74b0271cf48592451 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 1 Oct 2026 01:58:10 -0300 Subject: [PATCH] =?UTF-8?q?feat(C230):=20expurgo=20de=20pe=C3=A7as=20fora?= =?UTF-8?q?=20do=20per=C3=ADodo=20eleitoral=20na=20Central?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O rascunho pré-campanha que o --publish-existing ressuscita precisa sair do catálogo de vez: o novo CLI content:instagram:prune faz hard delete da peça e da mídia privada (linha, arquivo e still do C226), então uma importação futura não a recria. Compartilha o mesmo núcleo de corte da retirada (data civil na Bahia, peça sem data no feed nunca é tocada) e espelha as guardas dos irmãos: --before obrigatório, TEQO_ENV casando o nome exato do banco, CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 e recibos JSON. Testes: planejador/parser/recibo em unit e guardas do CLI em subprocesso. Runbook e changelog atualizados. --- docs/changelog/2026-10-01-c230-prune.md | 1 + docs/ops/teqo-1313-deploy.md | 27 ++ package.json | 1 + scripts/lib/instagramContentPlan.mjs | 176 +++++++++++- scripts/prune-instagram-content.mjs | 272 ++++++++++++++++++ tests/unit/instagramContentPlan.unit.spec.ts | 117 ++++++++ .../instagramContentPruneCli.unit.spec.ts | 90 ++++++ 7 files changed, 670 insertions(+), 14 deletions(-) create mode 100644 docs/changelog/2026-10-01-c230-prune.md create mode 100644 scripts/prune-instagram-content.mjs create mode 100644 tests/unit/instagramContentPruneCli.unit.spec.ts diff --git a/docs/changelog/2026-10-01-c230-prune.md b/docs/changelog/2026-10-01-c230-prune.md new file mode 100644 index 00000000..b3710713 --- /dev/null +++ b/docs/changelog/2026-10-01-c230-prune.md @@ -0,0 +1 @@ +**C230-followup — expurgo das peças fora do período eleitoral (2026-10-01):** nasce o terceiro CLI irmão da Central, `pnpm content:instagram:prune --before YYYY-MM-DD [--scan-days 365] [--apply]`: diferente da retirada (kill switch), ele faz **hard delete** da peça e da mídia privada — linha, arquivo e still do C226 — para que o rascunho pré-campanha não seja ressuscitado por uma importação futura nem publicado de novo pelo `--publish-existing` (que lê todo o feed varrido, não só a janela `--days`, causa da recorrência). O planejamento puro compartilha o **mesmo núcleo de corte** da retirada (`planContentPiecesBeforeCutoff` — extraído no `scripts/lib/instagramContentPlan.mjs`, sem segunda regra de data): posts estritamente antes da data civil na Bahia entram; peça que o feed oficial não consegue datar **nunca é tocada** (fail-closed contra apagar peça de campanha) e é nomeada no recibo. Guardas espelhando os irmãos: `--before` obrigatório, `TEQO_ENV` casando o nome exato do banco, `CONTENT_INSTAGRAM_PRUNE_CONFIRM=1` fora de teste, recibos JSON com o período e as falhas, e revalidação da Central só quando alguma publicada saiu. Testes: unit do planejador/parser/recibo e das guardas do CLI em subprocesso. Runbook ganha a seção do expurgo em `docs/ops/teqo-1313-deploy.md`. Na mesma sessão, em produção: a importação da janela de campanha trouxe **5 publicações novas** (2 carrosséis como peça-link) e o `--publish-existing` publicou **25 rascunhos prontos** — incluindo **19 peças de 12–15/08** que a varredura profunda do feed ressuscitou e que a retirada (`--before 2026-08-16`) devolveu a rascunho — e a falha de transcrição da peça do post de 21/09 foi reprocessada e publicada (a de 06/08 falhou de novo e segue rascunho, fora do período). Em seguida o expurgo apagou **72 peças** (posts de 31/07 a 15/08: 71 rascunhos prontos + a falha de 06/08), 0 falhas, mídia e stills limpos; restou 1 rascunho sem data no feed no catálogo (#11, `DdsAC82NXsC`, peça-link "não encontrado"), preservado pelo fail-closed. Estado final: **281 peças publicadas** (todas ≥ 16/08), 1 rascunho; Central pública revalidada e servindo (apagada → 404). diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 810d6dc7..83e92b51 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1577,6 +1577,33 @@ docker compose --profile maintenance run --rm \ para datar as peças publicadas; reexecutar converge (o que já é rascunho sai do conjunto). O recibo nomeia cada peça retirada, o período e as falhas. +**Apagar peças anteriores a um corte (expurgo definitivo).** Diferente da +retirada, o `content:instagram:prune` faz **hard delete** da peça e da mídia +privada (arquivo e still do C226): a linha sai do catálogo, então uma importação +futura não a recria como rascunho nem o `--publish-existing` a publica de novo. +Use quando a peça fora do período não deve mais existir — o caso clássico é o +rascunho pré-campanha que o `--publish-existing` ressuscita. Peça que o feed +oficial não consegue datar **nunca é tocada** (apagar sem data seria risco de +apagar peça de campanha) e aparece nomeada no recibo. + +```bash +# 1) plano (dry-run; lista o que sairia, o período e o que não tem data): +docker compose --profile maintenance run --rm \ + -v ~/content-instagram:/app/data/content-instagram \ + teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16 +# 2) expurgo (exige TEQO_ENV + confirmação; IRREVERSÍVEL): +docker compose --profile maintenance run --rm \ + -e TEQO_ENV=production -e CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 \ + -v ~/content-instagram:/app/data/content-instagram \ + teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16 --apply +``` + +`--scan-days ` (default **365**) é a profundidade da varredura do feed usada +para datar as peças; o plano varre rascunhos e publicadas. Reexecutar converge +(0 anteriores ao corte). O recibo nomeia cada peça apagada, o período e as +falhas; a revalidação da Central só roda se alguma publicada saiu (mídia órfã é +limpa após a linha, best-effort, como no delete da ficha). + Os recibos JSON ficam em `data/content-instagram/` do container — monte o volume acima para preservá-los. Com `--publish`, o CLI revalida a Central pública ao final pela rota oficial de revalidação diff --git a/package.json b/package.json index de22de62..e1fea77b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.mjs", "content:instagram:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-instagram-content.mjs", "content:instagram:withdraw": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/withdraw-instagram-content.mjs", + "content:instagram:prune": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/prune-instagram-content.mjs", "archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs", "faces:enroll": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-subject.mjs", "faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs", diff --git a/scripts/lib/instagramContentPlan.mjs b/scripts/lib/instagramContentPlan.mjs index 18f8d88c..1c636ccb 100644 --- a/scripts/lib/instagramContentPlan.mjs +++ b/scripts/lib/instagramContentPlan.mjs @@ -36,6 +36,9 @@ const INSTAGRAM_CONTENT_USAGE = const INSTAGRAM_CONTENT_WITHDRAW_USAGE = 'pnpm content:instagram:withdraw --before YYYY-MM-DD [--scan-days 90] [--apply] [--out data/content-instagram]' +const INSTAGRAM_CONTENT_PRUNE_USAGE = + 'pnpm content:instagram:prune --before YYYY-MM-DD [--scan-days 365] [--apply] [--out data/content-instagram]' + /** Report file stamp: the run's ISO instant with `:`/`.` swapped, like C215. */ export const instagramContentReportStamp = (runAt) => runAt.replace(/[:.]/g, '-') @@ -153,6 +156,63 @@ export const parseInstagramContentWithdrawCliArgs = (argv = process.argv.slice(2 return options } +/** + * Parses the prune CLI arguments. `--before` is a REQUIRED Bahia civil date + * (the campaign cutoff is a legal boundary, never a hidden default) and the + * `--scan-days` walk depth only bounds how far back the feed is read to date + * the catalogue rows; the default is deeper than the withdraw's because a prune + * targets the oldest rows of the catalogue (the whole electoral era fits in a + * year). + * + * @param {string[]} [argv] + */ +export const parseInstagramContentPruneCliArgs = (argv = process.argv.slice(2)) => { + const options = { + apply: false, + before: null, + scanDays: 365, + out: INSTAGRAM_CONTENT_DEFAULT_OUT_DIR, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = () => { + const next = argv[++index] + if (next === undefined || next.startsWith('--')) { + throw new Error(`faltou valor para ${arg} — ${INSTAGRAM_CONTENT_PRUNE_USAGE}.`) + } + return next + } + if (arg === '--help' || arg === '-h') options.help = true + else if (arg === '--apply') options.apply = true + else if (arg === '--before') options.before = value() + else if (arg === '--scan-days') options.scanDays = Number(value()) + else if (arg === '--out') options.out = value() + else throw new Error(`argumento desconhecido: ${arg} — ${INSTAGRAM_CONTENT_PRUNE_USAGE}.`) + } + + if (options.help) return options + if (options.before === null) { + throw new Error(`--before é obrigatório — ${INSTAGRAM_CONTENT_PRUNE_USAGE}.`) + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(options.before))) { + throw new Error('--before deve ser uma data civil YYYY-MM-DD.') + } + try { + contentPieceProfilePeriodBounds({ since: options.before }) + } catch { + throw new Error('--before deve ser uma data civil válida.') + } + if (!Number.isInteger(options.scanDays) || options.scanDays < 1 || options.scanDays > 365) { + throw new Error('--scan-days deve ser um inteiro entre 1 e 365.') + } + if (options.out.split(/[\\/]/).includes('..')) { + throw new Error('--out não pode escapar do diretório do repo (sem "..").') + } + return options +} + /** * The publish rule of the import: only a piece the pipeline finished (`pronto`) * goes public, and only when the run asked for it. A `falhou` piece stays a @@ -218,11 +278,11 @@ export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => { } /** - * Splits the published set by the civil cutoff: a post strictly BEFORE the - * `before` civil day (Bahia time, the electoral calendar's timezone) is - * withdrawn; the boundary instant comes from the domain owner of the civil - * dates, never a re-spelled offset. A piece whose post is not in the feed is - * named and left untouched. + * The one cutoff split shared by the withdraw and prune plans: a post strictly + * BEFORE the `before` civil day (Bahia time, the electoral calendar's timezone) + * matches; the boundary instant comes from the domain owner of the civil dates, + * never a re-spelled offset. A piece whose post is not in the feed is named and + * left untouched (fail-closed on an unknown date). * * @param {{ * pieces: Array<{ id: number, title?: string | null, sourceUrl?: string | null }>, @@ -230,11 +290,11 @@ export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => { * before: string, * }} input */ -export const planInstagramContentWithdraw = ({ pieces, posts, before }) => { +const planContentPiecesBeforeCutoff = ({ pieces, posts, before }) => { const { fromIso } = contentPieceProfilePeriodBounds({ since: before }) const cutoffMs = Date.parse(fromIso) const dates = instagramContentPostDates(posts) - const toWithdraw = [] + const matched = [] const undateable = [] for (const piece of pieces) { @@ -245,7 +305,7 @@ export const planInstagramContentWithdraw = ({ pieces, posts, before }) => { continue } if (timestamp < cutoffMs) { - toWithdraw.push({ + matched.push({ ...piece, shortcode: link.shortcode, postTimestamp: timestamp, @@ -253,9 +313,32 @@ export const planInstagramContentWithdraw = ({ pieces, posts, before }) => { }) } } - toWithdraw.sort((left, right) => left.postTimestamp - right.postTimestamp) + matched.sort((left, right) => left.postTimestamp - right.postTimestamp) - return { cutoffIso: fromIso, toWithdraw, undateable } + return { cutoffIso: fromIso, matched, undateable } +} + +/** + * Splits the published set by the civil cutoff (the withdraw plan): posts before + * the day leave the public listing. + * + * @param {Parameters[0]} input + */ +export const planInstagramContentWithdraw = (input) => { + const { cutoffIso, matched, undateable } = planContentPiecesBeforeCutoff(input) + return { cutoffIso, toWithdraw: matched, undateable } +} + +/** + * Splits the catalogue set by the civil cutoff (the prune plan): every piece + * before the day — draft or published — is matched for deletion. Same dating + * authority and same fail-closed on undateable rows as the withdraw plan. + * + * @param {Parameters[0]} input + */ +export const planInstagramContentPrune = (input) => { + const { cutoffIso, matched, undateable } = planContentPiecesBeforeCutoff(input) + return { cutoffIso, toDelete: matched, undateable } } /** @@ -349,7 +432,7 @@ export const formatInstagramContentReport = (report) => { return lines } -const WITHDRAW_SAMPLE_LIMIT = 10 +const CUTOFF_SAMPLE_LIMIT = 10 /** * The withdraw receipt lines the operator reads (and the JSON mirrors): the @@ -386,13 +469,13 @@ export const formatInstagramContentWithdrawReport = (report) => { lines.push( `período das retiradas: ${new Date(Math.min(...dates)).toISOString()} → ${new Date(Math.max(...dates)).toISOString()}`, ) - for (const entry of toWithdraw.slice(0, WITHDRAW_SAMPLE_LIMIT)) { + for (const entry of toWithdraw.slice(0, CUTOFF_SAMPLE_LIMIT)) { lines.push( ` - ${entry.shortcode} (${entry.postDate.slice(0, 10)}) ${entry.title ?? ''}`.trim(), ) } - if (toWithdraw.length > WITHDRAW_SAMPLE_LIMIT) { - lines.push(` … +${toWithdraw.length - WITHDRAW_SAMPLE_LIMIT}`) + if (toWithdraw.length > CUTOFF_SAMPLE_LIMIT) { + lines.push(` … +${toWithdraw.length - CUTOFF_SAMPLE_LIMIT}`) } } for (const entry of undateable) { @@ -413,3 +496,68 @@ export const formatInstagramContentWithdrawReport = (report) => { if (report.reportPath) lines.push(`recibo: ${report.reportPath}`) return lines } + +/** + * The prune receipt lines the operator reads (and the JSON mirrors): the + * cutoff, what the feed could date, the set matched for deletion with its + * period and the pieces the feed could not date (never touched, always named). + * The apply line names the hard delete — the row and its private media leave + * the catalogue, unlike the withdraw's reversible kill switch. + * + * @param {{ + * target: string, + * mode: 'plan' | 'apply', + * before: string, + * scanDays: number, + * feedCount: number, + * piecesCount: number, + * plan: ReturnType, + * deleted?: number, + * failures?: Array<{ id: number, reason: string }>, + * revalidation?: { attempted: boolean, ok: boolean, reason: string | null }, + * reportPath?: string | null, + * }} report + */ +export const formatInstagramContentPruneReport = (report) => { + const lines = [] + const { toDelete, undateable } = report.plan + const dates = toDelete.map((entry) => entry.postTimestamp) + + lines.push(`[content:instagram:prune] alvo: ${report.target} | modo: ${report.mode}`) + lines.push( + `corte: ${report.before} (Bahia; posts anteriores) · varredura: ${report.scanDays} dias · feed: ${report.feedCount} mídias`, + ) + lines.push( + `no catálogo: ${report.piecesCount} · anteriores ao corte: ${toDelete.length} · sem data no feed: ${undateable.length}`, + ) + if (toDelete.length > 0) { + lines.push( + `período das peças: ${new Date(Math.min(...dates)).toISOString()} → ${new Date(Math.max(...dates)).toISOString()}`, + ) + for (const entry of toDelete.slice(0, CUTOFF_SAMPLE_LIMIT)) { + lines.push( + ` - #${entry.id} ${entry.shortcode} (${entry.postDate.slice(0, 10)}) ${entry.title ?? ''}`.trim(), + ) + } + if (toDelete.length > CUTOFF_SAMPLE_LIMIT) { + lines.push(` … +${toDelete.length - CUTOFF_SAMPLE_LIMIT}`) + } + } + for (const entry of undateable) { + lines.push( + ` ? sem data: #${entry.id} ${entry.title ?? ''} — ${entry.sourceUrl ?? 'sem URL'}`.trim(), + ) + } + if (report.mode === 'apply') { + lines.push( + `apagadas: ${report.deleted ?? 0} · falhas: ${report.failures?.length ?? 0} · revalidação: ${ + report.revalidation?.ok ? 'ok' : (report.revalidation?.reason ?? 'não tentada') + }`, + ) + for (const failure of report.failures ?? []) { + lines.push(` - falha #${failure.id}: ${failure.reason}`) + } + } + if (report.reportPath) lines.push(`recibo: ${report.reportPath}`) + return lines +} diff --git a/scripts/prune-instagram-content.mjs b/scripts/prune-instagram-content.mjs new file mode 100644 index 00000000..26e2f0e0 --- /dev/null +++ b/scripts/prune-instagram-content.mjs @@ -0,0 +1,272 @@ +/** + * C230-followup — hard-deletes from the Central de Conteúdos the pieces whose + * original Instagram post is BEFORE a civil cutoff: the off-period rows leave + * the catalogue (row + private media) so a later import/publish run can never + * surface them again. The cutoff (`--before`, required) is a Bahia civil date + * and the post dates come from the official feed; this is the irreversible + * sibling of `content:instagram:withdraw` (which only unpublishes). A piece the + * feed cannot date is NEVER touched, only named — deleting on an unknown date + * would risk a campaign-period piece. + * + * Modes: + * pnpm content:instagram:prune --before 2026-08-16 + * plan/dry-run (default): reads the feed and the DB, lists and writes nothing + * pnpm content:instagram:prune --before 2026-08-16 --apply + * deletes; needs CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 on a non-local target and + * the declared TEQO_ENV matching the exact database name + * + * Options: --scan-days (feed walk depth to date the catalogue rows, default + * 365), --out (receipts, default `data/content-instagram`), --help. + * + * Runbook: on the homeserver, inside the compose maintenance service with the + * stack env file — see docs/ops/teqo-1313-deploy.md. + */ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import { getPayload } from 'payload' + +import { CONTENT_MEDIA_SLUG } from '../src/lib/contentPiece.ts' +import { contentPieceFrameFilename } from '../src/lib/contentPieceFrame.ts' +import { + assertEnvironmentDatabaseTarget, + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, +} from './lib/cli.mjs' +import { + loadInstagramContentWindowFeed, + revalidateInstagramContentCentral, +} from './lib/instagramContentCli.mjs' +import { + formatInstagramContentPruneReport, + instagramContentReportStamp, + parseInstagramContentPruneCliArgs, + planInstagramContentPrune, +} from './lib/instagramContentPlan.mjs' + +loadCliEnv() + +const die = dieWithLabel('content:instagram:prune') + +const WRITE_CONFIRM_FLAG = 'CONTENT_INSTAGRAM_PRUNE_CONFIRM' + +const HELP = `Uso: pnpm content:instagram:prune --before YYYY-MM-DD [opções] + +APAGA (hard delete, irreversível) da Central as peças cujo post original no +Instagram é ANTERIOR à data de corte — rascunhos e publicadas. A linha e a +mídia privada saem do catálogo, então uma importação futura não as recria nem +as publica. Peça que o feed oficial não consegue datar nunca é tocada (seria +risco de apagar peça de campanha) e aparece nomeada no recibo. Sem --apply, é um +plano read-only. + +Opções: + --before corte civil na Bahia (obrigatório; posts anteriores saem) + --scan-days profundidade da varredura do feed para datar as peças + (default 365; 1..365) + --apply executa o expurgo (exige ${WRITE_CONFIRM_FLAG}=1 em alvo + não-local) + --out recibos JSON (default data/content-instagram) + --help esta ajuda + +Flags: TEQO_ENV=staging|production (obrigatória no --apply fora de teste; o nome +do banco tem de casar) e ${WRITE_CONFIRM_FLAG}=1 para a escrita. +` + +const writeReport = async (options, report, suffix = '') => { + const stamp = instagramContentReportStamp(report.runAt) + const reportPath = join(options.out, `${stamp}${suffix}.json`) + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + return reportPath +} + +const reportLines = (report) => + formatInstagramContentPruneReport({ + target: report.target, + mode: report.mode, + before: report.before, + scanDays: report.scanDays, + feedCount: report.feed.count, + piecesCount: report.piecesCount, + plan: report.plan, + deleted: report.deleted, + failures: report.failures, + revalidation: report.revalidation, + reportPath: report.reportPath, + }) + +/** + * Deletes the piece row, then its private media and the C226 frame (a separate + * `contentMedia` row under a deterministic filename), both best-effort after the + * row is gone — the same order and tolerance as the C222 delete action, without + * an actor. Returns whether the deleted piece was published, so the caller only + * busts the public Central when the delete could have changed it. + * + * @param {import('payload').Payload} payload + * @param {{ id: number, status?: string | null }} entry + */ +const deleteContentPieceSystem = async (payload, entry) => { + const deleted = await payload.delete({ + collection: 'contentPiece', + id: entry.id, + depth: 0, + select: { media: true }, + // Intentional admin bypass: the ops prune owns the rows it listed. + overrideAccess: true, + }) + + const mediaId = typeof deleted.media === 'number' ? deleted.media : (deleted.media?.id ?? null) + if (mediaId !== null) { + await payload + .delete({ + collection: CONTENT_MEDIA_SLUG, + id: mediaId, + // Intentional admin bypass: cleanup of the file that belonged to the + // piece this run was told to prune. + overrideAccess: true, + }) + .catch(() => undefined) + } + + const frame = await payload + .find({ + collection: CONTENT_MEDIA_SLUG, + where: { filename: { equals: contentPieceFrameFilename(entry.id) } }, + limit: 1, + depth: 0, + // Intentional admin bypass: the derived-file probe, same as C226. + overrideAccess: true, + }) + .catch(() => null) + const frameId = frame?.docs?.[0]?.id ?? null + if (frameId !== null) { + await payload + .delete({ collection: CONTENT_MEDIA_SLUG, id: frameId, overrideAccess: true }) + .catch(() => undefined) + } + + return entry.status === 'publicado' +} + +async function main() { + let options + try { + options = parseInstagramContentPruneCliArgs() + } catch (error) { + die(error instanceof Error ? error.message : String(error)) + } + if (options.help) { + console.log(HELP) + process.exit(0) + } + + if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') + const mode = options.apply ? 'apply' : 'plan' + + if (mode === 'apply') { + assertWriteConfirm({ + label: 'content:instagram:prune', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm content:instagram:prune --before --apply', + }) + assertEnvironmentDatabaseTarget() + } + + console.log( + `[content:instagram:prune] alvo: ${databaseTarget()} | modo: ${mode} | corte: ${options.before} (Bahia)`, + ) + + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + + const window = await loadInstagramContentWindowFeed({ payload, days: options.scanDays }) + if ('error' in window) { + die( + window.error === 'unavailable' + ? 'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — o expurgo precisa das datas oficiais do feed.' + : 'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.', + ) + } + const { feed } = window + + const pieces = await payload.find({ + collection: 'contentPiece', + depth: 0, + limit: 0, + pagination: false, + select: { title: true, sourceUrl: true, status: true }, + // Intentional admin bypass: the ops run must see every catalogue row, + // including drafts no actor could read. + overrideAccess: true, + }) + + const plan = planInstagramContentPrune({ + pieces: pieces.docs, + posts: feed.posts, + before: options.before, + }) + + const runAt = new Date().toISOString() + const report = { + runAt, + mode, + target: databaseTarget(), + before: options.before, + scanDays: options.scanDays, + cutoffIso: plan.cutoffIso, + feed: { username: feed.username, count: feed.posts.length }, + piecesCount: pieces.docs.length, + plan, + reportPath: null, + } + + if (mode === 'plan') { + report.reportPath = await writeReport(options, report) + for (const line of reportLines(report)) console.log(line) + console.log( + `\n[content:instagram:prune] plano sem escrita — para apagar: --apply com ${WRITE_CONFIRM_FLAG}=1 (e TEQO_ENV).`, + ) + process.exit(0) + } + + const startedAt = Date.now() + let deleted = 0 + let deletedPublished = 0 + const failures = [] + for (const entry of plan.toDelete) { + try { + if (await deleteContentPieceSystem(payload, entry)) deletedPublished += 1 + deleted += 1 + if (deleted % 25 === 0) console.log(`[content:instagram:prune] ${deleted}…`) + } catch (error) { + failures.push({ + id: entry.id, + reason: error instanceof Error && error.message !== '' ? error.message : 'falha', + }) + } + } + + let revalidation = { attempted: false, ok: false, reason: null } + if (deletedPublished > 0) revalidation = await revalidateInstagramContentCentral() + + report.deleted = deleted + report.deletedPublished = deletedPublished + report.failures = failures + report.revalidation = revalidation + report.durationMs = Date.now() - startedAt + report.reportPath = await writeReport(options, report, '-prune') + + for (const line of reportLines(report)) console.log(line) + console.log(`\n[content:instagram:prune] recibo JSON: ${report.reportPath}`) + + if (deleted === 0 && plan.toDelete.length > 0) { + die('nenhuma peça foi apagada — veja o recibo.') + } + process.exit(0) +} + +main().catch((error) => { + die(error instanceof Error && error.message !== '' ? error.message : 'falha inesperada.') +}) diff --git a/tests/unit/instagramContentPlan.unit.spec.ts b/tests/unit/instagramContentPlan.unit.spec.ts index 33e187d1..e64a8763 100644 --- a/tests/unit/instagramContentPlan.unit.spec.ts +++ b/tests/unit/instagramContentPlan.unit.spec.ts @@ -8,14 +8,17 @@ import { } from '@/lib/contentPieceProfileWindow' import { + formatInstagramContentPruneReport, formatInstagramContentReport, formatInstagramContentWithdrawReport, instagramContentFeedIdentityUrls, instagramContentPostDates, instagramContentReportStamp, parseInstagramContentCliArgs, + parseInstagramContentPruneCliArgs, parseInstagramContentWithdrawCliArgs, planInstagramContentDraftsToPublish, + planInstagramContentPrune, planInstagramContentWindow, planInstagramContentWithdraw, shouldPublishImportedPiece, @@ -255,6 +258,120 @@ describe('pre-campaign withdrawal (C230-followup)', () => { }) }) +describe('pre-campaign pruning (C230-followup)', () => { + const cataloguePosts = [ + post('KEEP', { timestamp: '2026-08-16T00:00:00-03:00' }), + post('DROP-LATE', { timestamp: '2026-08-15T23:59:59-03:00' }), + post('DROP-EARLY', { timestamp: '2026-07-31T09:00:00-03:00' }), + ] + const catalogue = [ + { id: 1, title: 'KEEP', sourceUrl: 'https://www.instagram.com/reel/KEEP/', status: 'rascunho' }, + { + id: 2, + title: 'DROP-LATE', + sourceUrl: 'https://www.instagram.com/p/DROP-LATE/', + status: 'publicado', + }, + { + id: 3, + title: 'DROP-EARLY', + sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/', + status: 'rascunho', + }, + { + id: 4, + title: 'SEM-DATA', + sourceUrl: 'https://www.instagram.com/reel/FORA-DO-FEED/', + status: 'rascunho', + }, + { id: 5, title: 'SEM-URL', sourceUrl: null, status: 'rascunho' }, + ] + + it('matches every piece before the cutoff, drafts included, sharing the withdraw split', () => { + const plan = planInstagramContentPrune({ + pieces: catalogue, + posts: cataloguePosts, + before: '2026-08-16', + }) + const withdrawn = planInstagramContentWithdraw({ + pieces: catalogue, + posts: cataloguePosts, + before: '2026-08-16', + }) + + // 16/08 00:00 na Bahia = 03:00Z: a noite do dia 15 e tudo antes sai; peça + // sem data no feed nunca entra no conjunto (fail-closed). + expect(plan.cutoffIso).toBe('2026-08-16T03:00:00.000Z') + expect(plan.toDelete.map((piece) => piece.id)).toEqual([3, 2]) + expect(plan.toDelete.map((piece) => piece.postDate.slice(0, 10))).toEqual([ + '2026-07-31', + '2026-08-16', + ]) + expect(plan.undateable.map((piece) => piece.id)).toEqual([4, 5]) + expect(plan.toDelete.map((piece) => piece.id)).toEqual( + withdrawn.toWithdraw.map((piece) => piece.id), + ) + }) + + it('parses the prune arguments with the deep default scan', () => { + expect(parseInstagramContentPruneCliArgs(['--before', '2026-08-16'])).toEqual({ + apply: false, + before: '2026-08-16', + scanDays: 365, + out: 'data/content-instagram', + help: false, + }) + expect( + parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--apply', '--scan-days', '90']), + ).toMatchObject({ apply: true, before: '2026-08-16', scanDays: 90 }) + expect(() => parseInstagramContentPruneCliArgs([])).toThrow(/--before é obrigatório/) + expect(() => parseInstagramContentPruneCliArgs(['--before', '16/08/2026'])).toThrow( + /YYYY-MM-DD/, + ) + expect(() => parseInstagramContentPruneCliArgs(['--before', '2026-02-30'])).toThrow( + /data civil válida/, + ) + expect(() => + parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days']), + ).toThrow(/faltou valor/) + expect(() => + parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days', '0']), + ).toThrow(/--scan-days/) + expect(() => parseInstagramContentPruneCliArgs(['--force'])).toThrow(/argumento desconhecido/) + expect(parseInstagramContentPruneCliArgs(['--help'])).toMatchObject({ help: true }) + }) + + it('reports the cutoff, the period, the catalogue size and the undateable rows', () => { + const plan = planInstagramContentPrune({ + pieces: catalogue, + posts: cataloguePosts, + before: '2026-08-16', + }) + const lines = formatInstagramContentPruneReport({ + target: '127.0.0.1/teqo_1313', + mode: 'apply', + before: '2026-08-16', + scanDays: 365, + feedCount: 3, + piecesCount: 5, + plan, + deleted: 2, + failures: [], + revalidation: { attempted: true, ok: true, reason: null }, + reportPath: 'data/content-instagram/x-prune.json', + }) + + const text = lines.join('\n') + expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply') + expect(text).toContain('corte: 2026-08-16 (Bahia; posts anteriores)') + expect(text).toContain('no catálogo: 5 · anteriores ao corte: 2 · sem data no feed: 2') + expect(text).toContain('DROP-EARLY (2026-07-31)') + expect(text).toContain('? sem data: #4 SEM-DATA') + expect(text).toContain('apagadas: 2 · falhas: 0 · revalidação: ok') + expect(text).toContain('recibo: data/content-instagram/x-prune.json') + }) +}) + describe('summaries and receipt', () => { it('publishes only a ready piece when the run asked for it', () => { expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: true })).toBe(true) diff --git a/tests/unit/instagramContentPruneCli.unit.spec.ts b/tests/unit/instagramContentPruneCli.unit.spec.ts new file mode 100644 index 00000000..740cce24 --- /dev/null +++ b/tests/unit/instagramContentPruneCli.unit.spec.ts @@ -0,0 +1,90 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +// C230-followup — the prune CLI's fail-closed write guards, exercised as a +// subprocess: each refusal must happen before any Payload import, feed call or +// DB connection (a delete is irreversible, so the guard is the last wall). + +const repoRoot = process.cwd() +const scriptPath = join(repoRoot, 'scripts', 'prune-instagram-content.mjs') + +const run = (args: string[], env: Record = {}) => + spawnSync(process.execPath, [scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 60_000, + env: { + ...process.env, + NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs', + NODE_ENV: 'production', + VITEST: '', + DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313', + PAYLOAD_SECRET: 'test-secret', + CONTENT_INSTAGRAM_PRUNE_CONFIRM: '', + TEQO_ENV: '', + ALLOW_REMOTE_DB: '', + ...env, + }, + }) + +const output = (result: ReturnType) => `${result.stdout}${result.stderr}` + +describe('content:instagram:prune write guards (C230-followup)', { timeout: 30_000 }, () => { + it('prints the help even without a database', () => { + const result = run(['--help'], { DATABASE_URL: '' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('CONTENT_INSTAGRAM_PRUNE_CONFIRM') + expect(result.stdout).toContain('pnpm content:instagram:prune') + }) + + it('requires --before before touching anything', () => { + const result = run(['--apply']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('--before é obrigatório') + }) + + it('refuses an invalid cutoff before touching anything', () => { + const result = run(['--before', '16/08/2026']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('YYYY-MM-DD') + }) + + it('refuses --apply on a production target without the intent flag, before any DB import', () => { + const result = run(['--before', '2026-08-16', '--apply']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('CONTENT_INSTAGRAM_PRUNE_CONFIRM=1') + expect(output(result)).not.toContain('| modo:') + }) + + it('requires the declared TEQO_ENV once the intent flag is set', () => { + const result = run(['--before', '2026-08-16', '--apply'], { + CONTENT_INSTAGRAM_PRUNE_CONFIRM: '1', + }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('TEQO_ENV') + }) + + it('refuses a target database that does not match the declared environment', () => { + const result = run(['--before', '2026-08-16', '--apply'], { + CONTENT_INSTAGRAM_PRUNE_CONFIRM: '1', + TEQO_ENV: 'staging', + }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('≠ "teqo_staging"') + }) + + it('refuses an unknown argument before touching anything', () => { + const result = run(['--force']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('argumento desconhecido') + }) +})