Engagements store partial dates as value + precision and track field_sources; lists (list/grouping/thread, grouping-only rating/review) keep dense item order and snapshot items in history; copies store price in cents. All three restore through restoreVersion. Co-Authored-By: Claude Opus 5.5 <[email protected]>
248 lines
8.3 KiB
TypeScript
248 lines
8.3 KiB
TypeScript
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<string, unknown>;
|
|
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<Record<string, unknown>>(r.custom_fields, {}),
|
|
sourceRef: r.source_ref,
|
|
createdAt: r.created_at,
|
|
updatedAt: r.updated_at,
|
|
deletedAt: r.deleted_at,
|
|
};
|
|
}
|
|
|
|
function snapshot(c: Copy): Record<string, unknown> {
|
|
const s: Record<string, unknown> = { 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<CopyPatch>,
|
|
currentWorkId: string | null,
|
|
): Promise<Partial<Row>> {
|
|
checkKeys("patch", patch, COPY_KEYS);
|
|
const c: Partial<Row> = {};
|
|
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<string, unknown>);
|
|
c.custom_fields = toJson(patch.customFields);
|
|
}
|
|
return c;
|
|
}
|
|
|
|
async function load(k: Executor, userId: string, copyId: string, includeDeleted = false): Promise<Copy> {
|
|
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<CopyPatch>,
|
|
actor: Actor,
|
|
extra: Partial<Pick<Row, "deleted_at">> = {},
|
|
): Promise<Copy> {
|
|
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<Row>): Pick<Copy, "workId" | "titleHint"> {
|
|
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<CopyPatch>,
|
|
actor: Actor = "user",
|
|
tx?: Executor,
|
|
): Promise<Copy> {
|
|
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<CopyPatch>,
|
|
actor: Actor = "user",
|
|
tx?: Executor,
|
|
): Promise<Copy> {
|
|
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<void> {
|
|
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<Copy[]> {
|
|
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<string, unknown>,
|
|
): Promise<void> {
|
|
const current = await load(k, userId, copyId, true);
|
|
const patch: Record<string, unknown> = {};
|
|
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<CopyPatch>, "user", extra);
|
|
}
|