diff --git a/src/scripts/generateNextMission.ts b/src/scripts/generateNextMission.ts index 94147b1..ac027b5 100644 --- a/src/scripts/generateNextMission.ts +++ b/src/scripts/generateNextMission.ts @@ -1,85 +1,162 @@ -import type { SanitizedConfig } from "payload"; +import type { Field, SanitizedConfig } from "payload"; import { getPayload } from "payload"; +import { Missions } from "@/collections/intelligence/Missions"; +import { createBinLogger } from "./lib/binFileLogger"; + +/** + * Deletes the `id` property from every array row in `data`, walking the + * collection's field schema (groups, tabs, rows, nested arrays). + * + * Payload array rows in Postgres carry their own primary keys (UUID strings). + * Cloning a doc whose array rows still hold the source doc's row ids makes the + * drizzle adapter insert rows whose ids already exist — unique-constraint + * violation, surfacing as `ValidationError: The following field is invalid: id`. + * + * Schema-driven on purpose: a blind deep-strip of `id` keys would also walk + * richText (Lexical) JSON blobs, which must be cloned verbatim. + */ +function stripArrayRowIds(data: unknown, fields: readonly Field[]): void { + if (!data || typeof data !== "object") return; + const record = data as Record; + + for (const field of fields) { + switch (field.type) { + case "array": { + const rows = record[field.name]; + if (Array.isArray(rows)) { + for (const row of rows) { + if (row && typeof row === "object") { + delete (row as Record).id; + stripArrayRowIds(row, field.fields); + } + } + } + break; + } + case "group": + if ("name" in field) { + stripArrayRowIds(record[field.name], field.fields); + } else { + stripArrayRowIds(record, field.fields); + } + break; + case "tabs": + for (const tab of field.tabs) { + if ("name" in tab) { + stripArrayRowIds(record[tab.name], tab.fields); + } else { + stripArrayRowIds(record, tab.fields); + } + } + break; + case "row": + stripArrayRowIds(record, field.fields); + break; + default: + break; + } + } +} export const script = async (config: SanitizedConfig) => { const payload = await getPayload({ config }); + const log = createBinLogger("generate-mission", payload.logger); - payload.logger.info("[Generate Mission] Starting next mission template generation..."); + try { + log.info("[Generate Mission] Starting next mission template generation..."); - const latest = await payload.find({ - collection: "missions", - where: { operationType: { equals: "main" } }, - sort: "-createdAt", - limit: 1, - depth: 0, - }); + const latest = await payload.find({ + collection: "missions", + where: { operationType: { equals: "main" } }, + sort: "-createdAt", + limit: 1, + depth: 0, + }); - const source = latest.docs[0]; - if (!source) { - payload.logger.info( - '[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.', + const source = latest.docs[0]; + if (!source) { + log.info( + '[Generate Mission] No "main" operation mission found; skipping. Create the first one manually.', + ); + process.exit(0); + } + + // Same logic as the Missions collection startDateTime default: the upcoming + // Saturday at 20:00, always at least a week out. + const today = new Date(); + const day = today.getDay(); + const diff = (6 - day) % 7; + const nextSaturday = new Date(today); + nextSaturday.setDate(today.getDate() + diff + 7); + nextSaturday.setHours(20, 0, 0, 0); + + const dateStamp = [ + nextSaturday.getFullYear(), + String(nextSaturday.getMonth() + 1).padStart(2, "0"), + String(nextSaturday.getDate()).padStart(2, "0"), + ].join(""); + + // The source may itself be a previously generated clone whose codeName + // already ends in a datestamp — strip it so reruns derive a stable + // codeName (same target Saturday → same codeName → idempotency check + // fires) instead of stacking a new suffix on every run. + const baseCodeName = source.codeName.replace(/-\d{8}$/, ""); + const codeName = `${baseCodeName}-${dateStamp}`; + + const existing = await payload.find({ + collection: "missions", + where: { codeName: { equals: codeName } }, + limit: 1, + depth: 0, + }); + + if (existing.docs.length > 0) { + log.info( + `[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`, + ); + process.exit(0); + } + + const { + id: _sourceId, + createdAt: _sourceCreatedAt, + updatedAt: _sourceUpdatedAt, + ...template + } = source; + + stripArrayRowIds(template, Missions.fields); + + const mission = await payload.create({ + collection: "missions", + data: { + ...template, + codeName, + // Bot-managed per-mission state must never be inherited: the clone + // would otherwise edit the source mission's attendance embed. + discordMessageId: null, + discordAttendanceHash: null, + discordAttendanceSentAt: null, + classification: { + ...template.classification, + startDateTime: nextSaturday.toISOString(), + }, + ownershipAndStatus: { + ...template.ownershipAndStatus, + status: "Concept", + }, + }, + overrideAccess: true, + depth: 0, + }); + + log.info( + `[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`, ); process.exit(0); - } - - // Same logic as the Missions collection startDateTime default: the upcoming - // Saturday at 20:00, always at least a week out. - const today = new Date(); - const day = today.getDay(); - const diff = (6 - day) % 7; - const nextSaturday = new Date(today); - nextSaturday.setDate(today.getDate() + diff + 7); - nextSaturday.setHours(20, 0, 0, 0); - - const dateStamp = [ - nextSaturday.getFullYear(), - String(nextSaturday.getMonth() + 1).padStart(2, "0"), - String(nextSaturday.getDate()).padStart(2, "0"), - ].join(""); - - const codeName = `${source.codeName}-${dateStamp}`; - - const existing = await payload.find({ - collection: "missions", - where: { codeName: { equals: codeName } }, - limit: 1, - depth: 0, - }); - - if (existing.docs.length > 0) { - payload.logger.info( - `[Generate Mission] Mission with codeName "${codeName}" already exists; skipping.`, + } catch (error) { + log.error( + `[Generate Mission] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, ); - process.exit(0); + process.exit(1); } - - const { - id: _sourceId, - createdAt: _sourceCreatedAt, - updatedAt: _sourceUpdatedAt, - ...template - } = source; - - const mission = await payload.create({ - collection: "missions", - data: { - ...template, - codeName, - classification: { - ...template.classification, - startDateTime: nextSaturday.toISOString(), - }, - ownershipAndStatus: { - ...template.ownershipAndStatus, - status: "Concept", - }, - }, - overrideAccess: true, - depth: 0, - }); - - payload.logger.info( - `[Generate Mission] Created next mission template: id=${mission.id} name="${mission.name}" codeName="${mission.codeName}" startDateTime=${mission.classification.startDateTime}`, - ); - process.exit(0); };