import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { fromJson, toJson } from "@sttracker/db/json"; import type { Database } from "@sttracker/db/schema"; import type { PartialDate } from "@sttracker/plugin-sdk/types"; import { customFieldsFor, validateCustomValues } from "./custom-fields.ts"; import { checkKeys, checkText, fromStoredDate, resolveVisibleWork, toStoredDate } from "./entries.ts"; import { NotFoundError, ValidationError } from "./errors.ts"; import { type Actor, type Executor, inTx, recordHistory } from "./history.ts"; /** An owned copy (spec ยง6.7). Always private. `workId` is null until matched; then `titleHint` names it. */ export interface CopyPatch { workId: string | null; titleHint: string | null; format: string | null; acquiredFrom: string | null; acquiredAt: PartialDate | null; /** Integer minor units (cents). */ priceCents: number | null; /** ISO 4217 code, e.g. "GBP". */ currency: string | null; notes: string | null; customFields: Record; sourceRef: string | null; } const COPY_KEYS = [ "workId", "titleHint", "format", "acquiredFrom", "acquiredAt", "priceCents", "currency", "notes", "customFields", "sourceRef", ] as const satisfies readonly (keyof CopyPatch)[]; export interface Copy extends CopyPatch { id: string; userId: string; createdAt: string; updatedAt: string; deletedAt: string | null; } type Row = Database["copies"]; function toCopy(r: Row): Copy { return { id: r.id, userId: r.user_id, workId: r.work_id, titleHint: r.title_hint, format: r.format, acquiredFrom: r.acquired_from, acquiredAt: fromStoredDate(r.acquired_at, r.acquired_precision), priceCents: r.price_cents, currency: r.currency, notes: r.notes, customFields: fromJson>(r.custom_fields, {}), sourceRef: r.source_ref, createdAt: r.created_at, updatedAt: r.updated_at, deletedAt: r.deleted_at, }; } function snapshot(c: Copy): Record { const s: Record = { deletedAt: c.deletedAt }; for (const k of COPY_KEYS) s[k] = c[k]; return s; } /** Validate a patch into columns. `workId` must be visible to the user; custom fields use copy-level definitions. */ async function toColumns( k: Executor, userId: string, patch: Partial, currentWorkId: string | null, ): Promise> { checkKeys("patch", patch, COPY_KEYS); const c: Partial = {}; let workId = currentWorkId; if ("workId" in patch) { workId = patch.workId === null ? null : (await resolveVisibleWork(k, userId, String(patch.workId))).id; c.work_id = workId; } if ("titleHint" in patch) c.title_hint = checkText("titleHint", patch.titleHint); if ("format" in patch) c.format = checkText("format", patch.format); if ("acquiredFrom" in patch) c.acquired_from = checkText("acquiredFrom", patch.acquiredFrom); if ("acquiredAt" in patch) { const d = toStoredDate("acquiredAt", patch.acquiredAt); c.acquired_at = d.value; c.acquired_precision = d.precision; } if ("priceCents" in patch) { const p = patch.priceCents; if (p !== null && (typeof p !== "number" || !Number.isInteger(p) || p < 0)) { throw new ValidationError("priceCents", "not_cents"); } c.price_cents = p; } if ("currency" in patch) { const cur = checkText("currency", patch.currency); if (cur !== null && !/^[A-Z]{3}$/.test(cur)) throw new ValidationError("currency", "invalid"); c.currency = cur; } if ("notes" in patch) c.notes = checkText("notes", patch.notes); if ("sourceRef" in patch) c.source_ref = checkText("sourceRef", patch.sourceRef); if ("customFields" in patch) { let mediaType: string | null = null; if (workId) { const w = await k.selectFrom("works").select("media_type").where("id", "=", workId).executeTakeFirst(); mediaType = w?.media_type ?? null; } const defs = await customFieldsFor(k, userId, { mediaType, level: "copy" }); validateCustomValues(defs, patch.customFields as Record); c.custom_fields = toJson(patch.customFields); } return c; } async function load(k: Executor, userId: string, copyId: string, includeDeleted = false): Promise { let q = k.selectFrom("copies").selectAll().where("id", "=", copyId).where("user_id", "=", userId); if (!includeDeleted) q = q.where("deleted_at", "is", null); const r = await q.executeTakeFirst(); if (!r) throw new NotFoundError("copy"); return toCopy(r); } async function write( k: Executor, current: Copy, patch: Partial, actor: Actor, extra: Partial> = {}, ): Promise { const cols = await toColumns(k, current.userId, patch, current.workId); const next = { ...current, ...patchFromCols(current, cols) }; if (next.workId === null && !next.titleHint?.trim()) throw new ValidationError("titleHint", "required"); await k .updateTable("copies") .set({ ...cols, ...extra, updated_at: nowIso() }) .where("id", "=", current.id) .execute(); const after = await load(k, current.userId, current.id, true); await recordHistory(k, { userId: current.userId, entity: "copy", entityId: current.id, before: snapshot(current), after: snapshot(after), actor, }); return after; } function patchFromCols(current: Copy, cols: Partial): Pick { return { workId: "work_id" in cols ? (cols.work_id ?? null) : current.workId, titleHint: "title_hint" in cols ? (cols.title_hint ?? null) : current.titleHint, }; } /** Create an owned copy. Needs a `workId` or a `titleHint`. Writes history. */ export async function createCopy( db: Db, userId: string, input: Partial, actor: Actor = "user", tx?: Executor, ): Promise { return inTx(db, tx, async (k) => { const cols = await toColumns(k, userId, input, null); if (!cols.work_id && !cols.title_hint?.trim()) throw new ValidationError("titleHint", "required"); const now = nowIso(); const row: Row = { id: newId(), user_id: userId, work_id: null, title_hint: null, format: null, acquired_from: null, acquired_at: null, acquired_precision: "unknown", price_cents: null, currency: null, notes: null, custom_fields: toJson({}), source_ref: null, ...cols, created_at: now, updated_at: now, deleted_at: null, }; await k.insertInto("copies").values(row).execute(); const copy = toCopy(row); await recordHistory(k, { userId, entity: "copy", entityId: copy.id, before: null, after: snapshot(copy), actor }); return copy; }); } /** Patch a live copy; only the keys present are touched. Writes history. */ export async function updateCopy( db: Db, userId: string, copyId: string, patch: Partial, actor: Actor = "user", tx?: Executor, ): Promise { return inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), patch, actor)); } /** Soft-delete a copy. Writes history. */ export async function softDeleteCopy( db: Db, userId: string, copyId: string, actor: Actor = "user", tx?: Executor, ): Promise { await inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), {}, actor, { deleted_at: nowIso() })); } /** The user's live copies, optionally of one work, newest first. */ export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise { let q = db.k.selectFrom("copies").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null); if (filter.workId) q = q.where("work_id", "=", filter.workId); const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").execute(); return rows.map(toCopy); } /** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */ export async function restoreCopyFromHistory( k: Executor, userId: string, copyId: string, before: Record, ): Promise { const current = await load(k, userId, copyId, true); const patch: Record = {}; for (const key of COPY_KEYS) if (key in before) patch[key] = before[key]; const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {}; await write(k, current, patch as Partial, "user", extra); }