From c7471f7761a88def8a4dad5a13c8adc4eba57d84 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 19:35:15 +0100 Subject: [PATCH] feat(core): engagements, lists and copies with history restore 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 --- packages/core/src/copies.ts | 247 ++++++++++++++++ packages/core/src/engagements.ts | 263 +++++++++++++++++ packages/core/src/history.ts | 9 + packages/core/src/lists.ts | 384 +++++++++++++++++++++++++ packages/core/test/copies.test.ts | 76 +++++ packages/core/test/engagements.test.ts | 126 ++++++++ packages/core/test/lists.test.ts | 104 +++++++ 7 files changed, 1209 insertions(+) create mode 100644 packages/core/src/copies.ts create mode 100644 packages/core/src/engagements.ts create mode 100644 packages/core/src/lists.ts create mode 100644 packages/core/test/copies.test.ts create mode 100644 packages/core/test/engagements.test.ts create mode 100644 packages/core/test/lists.test.ts diff --git a/packages/core/src/copies.ts b/packages/core/src/copies.ts new file mode 100644 index 0000000..a8231cf --- /dev/null +++ b/packages/core/src/copies.ts @@ -0,0 +1,247 @@ +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); +} diff --git a/packages/core/src/engagements.ts b/packages/core/src/engagements.ts new file mode 100644 index 0000000..4c3c15d --- /dev/null +++ b/packages/core/src/engagements.ts @@ -0,0 +1,263 @@ +import { sql } from "kysely"; +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, + checkRating, + checkText, + type FieldSources, + fromStoredDate, + requireEntry, + toStoredDate, +} from "./entries.ts"; +import { NotFoundError, ValidationError } from "./errors.ts"; +import { type Actor, type Executor, inTx, recordHistory } from "./history.ts"; + +/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */ +export interface EngagementPatch { + startedAt: PartialDate | null; + finishedAt: PartialDate | null; + progress: Record; + platform: string | null; + rating: number | null; + notes: string | null; + customFields: Record; +} +export const ENGAGEMENT_PATCH_KEYS = [ + "startedAt", + "finishedAt", + "progress", + "platform", + "rating", + "notes", + "customFields", +] as const satisfies readonly (keyof EngagementPatch)[]; + +export interface Engagement extends EngagementPatch { + id: string; + entryId: string; + fieldSources: FieldSources; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +type Row = Database["engagements"]; + +function toEngagement(r: Row): Engagement { + return { + id: r.id, + entryId: r.entry_id, + startedAt: fromStoredDate(r.started_at, r.started_precision), + finishedAt: fromStoredDate(r.finished_at, r.finished_precision), + progress: fromJson>(r.progress, {}), + platform: r.platform, + rating: r.rating, + notes: r.notes, + customFields: fromJson>(r.custom_fields, {}), + fieldSources: fromJson(r.field_sources, {}), + createdAt: r.created_at, + updatedAt: r.updated_at, + deletedAt: r.deleted_at, + }; +} + +function snapshot(e: Engagement): Record { + const s: Record = { deletedAt: e.deletedAt }; + for (const k of ENGAGEMENT_PATCH_KEYS) s[k] = e[k]; + return s; +} + +/** Validate a patch and turn it into columns. */ +async function toColumns( + k: Executor, + userId: string, + mediaType: string, + patch: Partial, +): Promise> { + checkKeys("patch", patch, ENGAGEMENT_PATCH_KEYS); + const c: Partial = {}; + if ("startedAt" in patch) { + const d = toStoredDate("startedAt", patch.startedAt); + c.started_at = d.value; + c.started_precision = d.precision; + } + if ("finishedAt" in patch) { + const d = toStoredDate("finishedAt", patch.finishedAt); + c.finished_at = d.value; + c.finished_precision = d.precision; + } + if ("progress" in patch) { + const p = patch.progress; + if (p === null || typeof p !== "object" || Array.isArray(p)) throw new ValidationError("progress", "not_object"); + for (const [key, v] of Object.entries(p)) { + if (typeof v !== "string" && (typeof v !== "number" || !Number.isFinite(v))) { + throw new ValidationError(`progress.${key}`, "invalid"); + } + } + c.progress = toJson(p); + } + if ("platform" in patch) c.platform = checkText("platform", patch.platform); + if ("rating" in patch) c.rating = checkRating("rating", patch.rating); + if ("notes" in patch) c.notes = checkText("notes", patch.notes); + if ("customFields" in patch) { + const defs = await customFieldsFor(k, userId, { mediaType, level: "engagement" }); + validateCustomValues(defs, patch.customFields as Record); + c.custom_fields = toJson(patch.customFields); + } + return c; +} + +/** Load an engagement whose entry belongs to the user. */ +async function load( + k: Executor, + userId: string, + engagementId: string, + opts: { includeDeleted?: boolean } = {}, +): Promise<{ eg: Engagement; mediaType: string }> { + let q = k + .selectFrom("engagements as g") + .innerJoin("entries as e", "e.id", "g.entry_id") + .innerJoin("works as w", "w.id", "e.work_id") + .selectAll("g") + .select(["e.media_type_override", "w.media_type"]) + .where("g.id", "=", engagementId) + .where("e.user_id", "=", userId); + if (!opts.includeDeleted) q = q.where("g.deleted_at", "is", null); + const r = await q.executeTakeFirst(); + if (!r) throw new NotFoundError("engagement"); + return { eg: toEngagement(r), mediaType: r.media_type_override ?? r.media_type }; +} + +async function write( + k: Executor, + userId: string, + current: Engagement, + mediaType: string, + patch: Partial, + actor: Actor, + extra: Partial> = {}, +): Promise { + const cols = await toColumns(k, userId, mediaType, patch); + const fieldSources = { ...current.fieldSources }; + for (const key of Object.keys(patch)) fieldSources[key] = actor; + await k + .updateTable("engagements") + .set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() }) + .where("id", "=", current.id) + .execute(); + const { eg } = await load(k, userId, current.id, { includeDeleted: true }); + await recordHistory(k, { + userId, + entity: "engagement", + entityId: current.id, + before: snapshot(current), + after: snapshot(eg), + actor, + }); + return eg; +} + +/** Add an engagement to the user's live entry. Each patched key gets `field_sources[key] = actor`. */ +export async function addEngagement( + db: Db, + userId: string, + entryId: string, + patch: Partial, + actor: Actor, + tx?: Executor, +): Promise { + return inTx(db, tx, async (k) => { + const { mediaType } = await requireEntry(k, userId, entryId); + const cols = await toColumns(k, userId, mediaType, patch); + const fieldSources: FieldSources = {}; + for (const key of Object.keys(patch)) fieldSources[key] = actor; + const now = nowIso(); + const row: Row = { + id: newId(), + entry_id: entryId, + started_at: null, + started_precision: "unknown", + finished_at: null, + finished_precision: "unknown", + progress: toJson({}), + platform: null, + rating: null, + notes: null, + custom_fields: toJson({}), + ...cols, + field_sources: toJson(fieldSources), + created_at: now, + updated_at: now, + deleted_at: null, + }; + await k.insertInto("engagements").values(row).execute(); + const eg = toEngagement(row); + await recordHistory(k, { userId, entity: "engagement", entityId: eg.id, before: null, after: snapshot(eg), actor }); + return eg; + }); +} + +/** Patch a live engagement; only the keys present are touched. Writes history. */ +export async function updateEngagement( + db: Db, + userId: string, + engagementId: string, + patch: Partial, + actor: Actor, + tx?: Executor, +): Promise { + return inTx(db, tx, async (k) => { + const { eg, mediaType } = await load(k, userId, engagementId); + if (Object.keys(patch).length === 0) return eg; + return write(k, userId, eg, mediaType, patch, actor); + }); +} + +/** Soft-delete an engagement. Writes history. */ +export async function softDeleteEngagement( + db: Db, + userId: string, + engagementId: string, + actor: Actor, + tx?: Executor, +): Promise { + await inTx(db, tx, async (k) => { + const { eg, mediaType } = await load(k, userId, engagementId); + await write(k, userId, eg, mediaType, {}, actor, { deleted_at: nowIso() }); + }); +} + +/** The live engagements of the user's live entry, by start date (undated last), then creation. */ +export async function listEngagements(db: Db, userId: string, entryId: string): Promise { + await requireEntry(db.k, userId, entryId); + const rows = await db.k + .selectFrom("engagements") + .selectAll() + .where("entry_id", "=", entryId) + .where("deleted_at", "is", null) + .orderBy(sql`case when started_at is null then 1 else 0 end`) + .orderBy("started_at") + .orderBy("created_at") + .orderBy("id") + .execute(); + return rows.map(toEngagement); +} + +/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */ +export async function restoreEngagementFromHistory( + k: Executor, + userId: string, + engagementId: string, + before: Record, +): Promise { + const { eg, mediaType } = await load(k, userId, engagementId, { includeDeleted: true }); + const patch: Record = {}; + for (const key of ENGAGEMENT_PATCH_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, userId, eg, mediaType, patch as Partial, "user", extra); +} diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts index 094e5d9..34b8da6 100644 --- a/packages/core/src/history.ts +++ b/packages/core/src/history.ts @@ -3,8 +3,11 @@ 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 { restoreCopyFromHistory } from "./copies.ts"; +import { restoreEngagementFromHistory } from "./engagements.ts"; import { restoreEntryFromHistory } from "./entries.ts"; import { NotFoundError } from "./errors.ts"; +import { restoreListFromHistory } from "./lists.ts"; /** Who made a change. Plugin actors are `plugin::`; API tokens are `token:`. */ export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`; @@ -108,6 +111,12 @@ export async function restoreVersion(db: Db, userId: string, historyId: string): switch (h.entity as HistoryEntity) { case "entry": return restoreEntryFromHistory(tx, userId, h.entityId, h.before); + case "engagement": + return restoreEngagementFromHistory(tx, userId, h.entityId, h.before); + case "copy": + return restoreCopyFromHistory(tx, userId, h.entityId, h.before); + case "list": + return restoreListFromHistory(tx, userId, h.entityId, h.before); default: throw new NotFoundError("history"); } diff --git a/packages/core/src/lists.ts b/packages/core/src/lists.ts new file mode 100644 index 0000000..53b8e86 --- /dev/null +++ b/packages/core/src/lists.ts @@ -0,0 +1,384 @@ +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import type { Database, ListKind, Visibility } from "@sttracker/db/schema"; +import { checkKeys, checkRating, checkText, defaultVisibility, resolveVisibleWork, VISIBILITIES } from "./entries.ts"; +import { NotFoundError, ValidationError } from "./errors.ts"; +import { type Actor, type Executor, inTx, recordHistory } from "./history.ts"; + +const KINDS: readonly ListKind[] = ["list", "grouping", "thread"]; + +/** Spec §6.5. Only a `grouping` may carry `rating`/`review`. */ +export interface ListPatch { + name: string; + description: string; + kind: ListKind; + visibility: Visibility; + rating: number | null; + review: string | null; +} +const LIST_KEYS = ["name", "description", "kind", "visibility", "rating", "review"] as const satisfies readonly (keyof ListPatch)[]; + +export interface List extends ListPatch { + id: string; + userId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface ListItem { + id: string; + listId: string; + workId: string; + /** 0-based, dense. */ + position: number; + note: string | null; +} + +export interface ListWithItems extends List { + items: (ListItem & { work: { id: string; title: string; mediaType: string; year: number | null; coverUrl: string } })[]; +} + +type Row = Database["lists"]; +type ItemRow = Database["list_items"]; + +function toList(r: Row): List { + return { + id: r.id, + userId: r.user_id, + name: r.name, + description: r.description, + kind: r.kind, + visibility: r.visibility, + rating: r.rating, + review: r.review, + createdAt: r.created_at, + updatedAt: r.updated_at, + deletedAt: r.deleted_at, + }; +} + +const toItem = (r: ItemRow): ListItem => ({ + id: r.id, + listId: r.list_id, + workId: r.work_id, + position: r.position, + note: r.note, +}); + +/** The history snapshot: list fields, `deletedAt`, and the ordered items (so removals are restorable). */ +async function snapshot(k: Executor, l: List): Promise> { + const s: Record = { deletedAt: l.deletedAt }; + for (const key of LIST_KEYS) s[key] = l[key]; + s.items = (await items(k, l.id)).map((i) => ({ id: i.id, workId: i.workId, note: i.note })); + return s; +} + +function normalise(patch: Partial): Partial { + checkKeys("patch", patch, LIST_KEYS); + const out: Partial = {}; + if ("name" in patch) { + if (typeof patch.name !== "string" || !patch.name.trim()) throw new ValidationError("name", "required"); + out.name = patch.name.trim(); + } + if ("description" in patch) out.description = checkText("description", patch.description) ?? ""; + if ("kind" in patch) { + if (!KINDS.includes(patch.kind as ListKind)) throw new ValidationError("kind", "invalid"); + out.kind = patch.kind; + } + if ("visibility" in patch) { + if (!VISIBILITIES.includes(patch.visibility as Visibility)) throw new ValidationError("visibility", "invalid"); + out.visibility = patch.visibility; + } + if ("rating" in patch) out.rating = checkRating("rating", patch.rating); + if ("review" in patch) out.review = checkText("review", patch.review); + return out; +} + +function checkGrouping(l: Pick): void { + if (l.kind !== "grouping" && (l.rating !== null || l.review !== null)) { + throw new ValidationError(l.rating !== null ? "rating" : "review", "grouping_only"); + } +} + +function toColumns(p: Partial): Partial { + const c: Partial = {}; + if (p.name !== undefined) c.name = p.name; + if (p.description !== undefined) c.description = p.description; + if (p.kind !== undefined) c.kind = p.kind; + if (p.visibility !== undefined) c.visibility = p.visibility; + if (p.rating !== undefined) c.rating = p.rating; + if (p.review !== undefined) c.review = p.review; + return c; +} + +async function load(k: Executor, userId: string, listId: string, includeDeleted = false): Promise { + let q = k.selectFrom("lists").selectAll().where("id", "=", listId).where("user_id", "=", userId); + if (!includeDeleted) q = q.where("deleted_at", "is", null); + const r = await q.executeTakeFirst(); + if (!r) throw new NotFoundError("list"); + return toList(r); +} + +async function items(k: Executor, listId: string): Promise { + const rows = await k + .selectFrom("list_items") + .selectAll() + .where("list_id", "=", listId) + .orderBy("position") + .orderBy("id") + .execute(); + return rows.map(toItem); +} + +/** Rewrite positions to 0..n-1 in the given order. */ +async function renumber(k: Executor, ordered: ListItem[]): Promise { + for (const [i, it] of ordered.entries()) { + if (it.position !== i) await k.updateTable("list_items").set({ position: i }).where("id", "=", it.id).execute(); + } +} + +/** Run `change` on a list (live, user's), then bump `updated_at` and record history. */ +async function mutate( + k: Executor, + userId: string, + listId: string, + actor: Actor, + change: (l: List) => Promise, + opts: { includeDeleted?: boolean } = {}, +): Promise { + const before = await load(k, userId, listId, opts.includeDeleted); + const beforeSnap = await snapshot(k, before); + const result = await change(before); + await k.updateTable("lists").set({ updated_at: nowIso() }).where("id", "=", listId).execute(); + const after = await load(k, userId, listId, true); + await recordHistory(k, { + userId, + entity: "list", + entityId: listId, + before: beforeSnap, + after: await snapshot(k, after), + actor, + }); + return result; +} + +/** Create a list. Default kind `list`, visibility from user preferences. */ +export async function createList( + db: Db, + userId: string, + input: Partial & { name: string }, + actor: Actor = "user", + tx?: Executor, +): Promise { + return inTx(db, tx, async (k) => { + const p = normalise(input); + if (p.name === undefined) throw new ValidationError("name", "required"); + const now = nowIso(); + const row: Row = { + id: newId(), + user_id: userId, + name: p.name, + description: p.description ?? "", + kind: p.kind ?? "list", + visibility: p.visibility ?? (await defaultVisibility(k, userId)), + rating: p.rating ?? null, + review: p.review ?? null, + created_at: now, + updated_at: now, + deleted_at: null, + }; + checkGrouping(row); + await k.insertInto("lists").values(row).execute(); + const list = toList(row); + await recordHistory(k, { + userId, + entity: "list", + entityId: list.id, + before: null, + after: await snapshot(k, list), + actor, + }); + return list; + }); +} + +/** Patch a list's own fields. */ +export async function updateList( + db: Db, + userId: string, + listId: string, + patch: Partial, + actor: Actor = "user", + tx?: Executor, +): Promise { + return inTx(db, tx, async (k) => { + const p = normalise(patch); + await mutate(k, userId, listId, actor, async (l) => { + checkGrouping({ ...l, ...p }); + const cols = toColumns(p); + if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute(); + }); + return load(k, userId, listId); + }); +} + +/** Soft-delete a list (its items stay, for restore). */ +export async function softDeleteList( + db: Db, + userId: string, + listId: string, + actor: Actor = "user", + tx?: Executor, +): Promise { + await inTx(db, tx, (k) => + mutate(k, userId, listId, actor, async () => { + await k.updateTable("lists").set({ deleted_at: nowIso() }).where("id", "=", listId).execute(); + }), + ); +} + +/** Add a work to a list, at `position` (0-based; default: the end). */ +export async function addListItem( + db: Db, + userId: string, + listId: string, + input: { workId: string; note?: string | null; position?: number }, + actor: Actor = "user", + tx?: Executor, +): Promise { + return inTx(db, tx, (k) => + mutate(k, userId, listId, actor, async () => { + const work = await resolveVisibleWork(k, userId, input.workId); + const note = input.note === undefined ? null : checkText("note", input.note); + const current = await items(k, listId); + const at = clampIndex(input.position ?? current.length, current.length); + const item: ListItem = { id: newId(), listId, workId: work.id, position: current.length, note }; + await k + .insertInto("list_items") + .values({ id: item.id, list_id: listId, work_id: item.workId, position: item.position, note }) + .execute(); + current.splice(at, 0, item); + await renumber(k, current); + return { ...item, position: at }; + }), + ); +} + +function clampIndex(i: number, max: number): number { + if (typeof i !== "number" || !Number.isInteger(i)) throw new ValidationError("position", "not_integer"); + return Math.max(0, Math.min(i, max)); +} + +/** Load an item of one of the user's live lists. */ +async function loadItem(k: Executor, userId: string, itemId: string): Promise { + const r = await k + .selectFrom("list_items as i") + .innerJoin("lists as l", "l.id", "i.list_id") + .selectAll("i") + .where("i.id", "=", itemId) + .where("l.user_id", "=", userId) + .where("l.deleted_at", "is", null) + .executeTakeFirst(); + if (!r) throw new NotFoundError("list_item"); + return toItem(r); +} + +/** Move an item to `toIndex` (clamped to the list bounds). */ +export async function moveListItem( + db: Db, + userId: string, + itemId: string, + toIndex: number, + actor: Actor = "user", + tx?: Executor, +): Promise { + await inTx(db, tx, async (k) => { + const item = await loadItem(k, userId, itemId); + await mutate(k, userId, item.listId, actor, async () => { + const current = (await items(k, item.listId)).filter((i) => i.id !== itemId); + current.splice(clampIndex(toIndex, current.length), 0, item); + await renumber(k, current); + }); + }); +} + +/** Remove an item. The list's history keeps it, so `restoreVersion` can bring it back. */ +export async function removeListItem( + db: Db, + userId: string, + itemId: string, + actor: Actor = "user", + tx?: Executor, +): Promise { + await inTx(db, tx, async (k) => { + const item = await loadItem(k, userId, itemId); + await mutate(k, userId, item.listId, actor, async () => { + await k.deleteFrom("list_items").where("id", "=", itemId).execute(); + await renumber(k, await items(k, item.listId)); + }); + }); +} + +/** A live list of the user's with its items in order. */ +export async function getList(db: Db, userId: string, listId: string): Promise { + const list = await load(db.k, userId, listId); + const rows = await db.k + .selectFrom("list_items as i") + .innerJoin("works as w", "w.id", "i.work_id") + .selectAll("i") + .select(["w.title", "w.media_type", "w.year", "w.cover_url"]) + .where("i.list_id", "=", listId) + .orderBy("i.position") + .orderBy("i.id") + .execute(); + return { + ...list, + items: rows.map((r) => ({ + ...toItem(r), + work: { id: r.work_id, title: r.title, mediaType: r.media_type, year: r.year, coverUrl: r.cover_url }, + })), + }; +} + +/** The user's live lists, most recently updated first. */ +export async function listLists(db: Db, userId: string, filter: { kind?: ListKind } = {}): Promise { + let q = db.k.selectFrom("lists").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null); + if (filter.kind) q = q.where("kind", "=", filter.kind); + const rows = await q.orderBy("updated_at", "desc").orderBy("id", "desc").execute(); + return rows.map(toList); +} + +/** Called by `restoreVersion`: re-apply a history row's `before` (fields, deletion and items) as actor "user". */ +export async function restoreListFromHistory( + k: Executor, + userId: string, + listId: string, + before: Record, +): Promise { + const patch: Record = {}; + for (const key of LIST_KEYS) if (key in before) patch[key] = before[key]; + const p = normalise(patch as Partial); + await mutate( + k, + userId, + listId, + "user", + async (l) => { + checkGrouping({ ...l, ...p }); + const cols: Partial = toColumns(p); + if ("deletedAt" in before) cols.deleted_at = (before.deletedAt as string | null) ?? null; + if (Object.keys(cols).length) await k.updateTable("lists").set(cols).where("id", "=", listId).execute(); + if (Array.isArray(before.items)) { + await k.deleteFrom("list_items").where("list_id", "=", listId).execute(); + for (const [position, it] of (before.items as { id: string; workId: string; note: string | null }[]).entries()) { + await k + .insertInto("list_items") + .values({ id: it.id, list_id: listId, work_id: it.workId, position, note: it.note ?? null }) + .execute(); + } + } + }, + { includeDeleted: true }, + ); +} diff --git a/packages/core/test/copies.test.ts b/packages/core/test/copies.test.ts new file mode 100644 index 0000000..8b3c45f --- /dev/null +++ b/packages/core/test/copies.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { createCopy, listCopies, softDeleteCopy, updateCopy } from "../src/copies.ts"; +import { defineCustomField } from "../src/custom-fields.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { getHistory, restoreVersion } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("copies", (getDb) => { + test("createCopy stores price in cents and partial acquired date", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const c = await createCopy(db, userId, { + workId, + format: "hardback", + acquiredFrom: "Waterstones", + acquiredAt: { value: "2019-03", precision: "month" }, + priceCents: 1299, + currency: "GBP", + }); + expect(c.priceCents).toBe(1299); + expect(c.acquiredAt).toEqual({ value: "2019-03", precision: "month" }); + const row = await db.k.selectFrom("copies").selectAll().where("id", "=", c.id).executeTakeFirstOrThrow(); + expect(row.price_cents).toBe(1299); + expect(row.acquired_precision).toBe("month"); + + await expect(createCopy(db, userId, { workId, priceCents: 12.5 })).rejects.toThrow(ValidationError); + await expect(createCopy(db, userId, { workId, currency: "pounds" })).rejects.toThrow(ValidationError); + await expect(createCopy(db, userId, {})).rejects.toThrow(ValidationError); + }); + + test("unmatched copy keeps its title hint; listCopies filters by work", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const steam = await createCopy(db, userId, { titleHint: "HALF-LIFE 2", format: "Steam", sourceRef: "steam:220" }, "plugin:steam:c1"); + const book = await createCopy(db, userId, { workId }); + expect(steam.workId).toBeNull(); + expect(steam.titleHint).toBe("HALF-LIFE 2"); + expect((await listCopies(db, userId, {})).map((c) => c.id).sort()).toEqual([steam.id, book.id].sort()); + expect((await listCopies(db, userId, { workId })).map((c) => c.id)).toEqual([book.id]); + }); + + test("updateCopy writes history; soft delete and restore", async () => { + const db = getDb(); + const userId = await makeUser(db); + const def = await defineCustomField(db, userId, { name: "Signed", type: "boolean", level: "copy" }); + const c = await createCopy(db, userId, { workId: await makeWork(db), notes: "shelf 3" }); + const up = await updateCopy(db, userId, c.id, { notes: "shelf 4", customFields: { [def.id]: true } }); + expect(up.notes).toBe("shelf 4"); + expect(up.customFields).toEqual({ [def.id]: true }); + await expect(updateCopy(db, userId, c.id, { customFields: { [def.id]: "yes" } })).rejects.toThrow(ValidationError); + const [h] = await getHistory(db, userId, "copy", c.id); + expect(h!.before).toEqual({ notes: "shelf 3", customFields: {} }); + + await softDeleteCopy(db, userId, c.id); + expect(await listCopies(db, userId, {})).toEqual([]); + const [del] = await getHistory(db, userId, "copy", c.id); + await restoreVersion(db, userId, del!.id); + expect((await listCopies(db, userId, {})).map((x) => x.id)).toEqual([c.id]); + }); + + test("another user's copies are not found", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const c = await createCopy(db, ada, { titleHint: "x" }); + await expect(updateCopy(db, bob, c.id, { notes: "y" })).rejects.toThrow(NotFoundError); + await expect(softDeleteCopy(db, bob, c.id)).rejects.toThrow(NotFoundError); + expect(await listCopies(db, bob, {})).toEqual([]); + await expect(createCopy(db, bob, { workId: await makeWork(db, { ownerUserId: ada }) })).rejects.toThrow( + NotFoundError, + ); + }); +}); diff --git a/packages/core/test/engagements.test.ts b/packages/core/test/engagements.test.ts new file mode 100644 index 0000000..93d9f04 --- /dev/null +++ b/packages/core/test/engagements.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { defineCustomField } from "../src/custom-fields.ts"; +import { + addEngagement, + listEngagements, + softDeleteEngagement, + updateEngagement, +} from "../src/engagements.ts"; +import { upsertEntry } from "../src/entries.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { getHistory, restoreVersion } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("engagements", (getDb) => { + async function setup(mediaType = "book") { + const db = getDb(); + const userId = await makeUser(db); + const entry = await upsertEntry(db, userId, await makeWork(db, { mediaType }), { status: "completed" }, "user"); + return { db, userId, entry }; + } + + test("engagement with year precision keeps '2019'", async () => { + const { db, userId, entry } = await setup(); + const eg = await addEngagement( + db, + userId, + entry.id, + { finishedAt: { value: "2019", precision: "year" }, platform: "Kindle" }, + "user", + ); + expect(eg.finishedAt).toEqual({ value: "2019", precision: "year" }); + expect(eg.startedAt).toBeNull(); + expect(eg.fieldSources).toEqual({ finishedAt: "user", platform: "user" }); + const row = await db.k.selectFrom("engagements").selectAll().where("id", "=", eg.id).executeTakeFirstOrThrow(); + expect(row.finished_at).toBe("2019"); + expect(row.finished_precision).toBe("year"); + expect(row.started_at).toBeNull(); + expect(row.started_precision).toBe("unknown"); + const [listed] = await listEngagements(db, userId, entry.id); + expect(listed!.finishedAt).toEqual({ value: "2019", precision: "year" }); + }); + + test("partial dates must match their precision", async () => { + const { db, userId, entry } = await setup(); + const bad = [ + { value: "2019-03", precision: "year" }, + { value: "2019-02-30", precision: "day" }, + { value: "", precision: "day" }, + { value: "2019", precision: "bogus" }, + ]; + for (const d of bad) { + await expect(addEngagement(db, userId, entry.id, { startedAt: d as never }, "user")).rejects.toThrow( + ValidationError, + ); + } + const ok = await addEngagement( + db, + userId, + entry.id, + { startedAt: { value: "2019-03-04T20:15:00Z", precision: "day" }, finishedAt: { value: "", precision: "unknown" } }, + "user", + ); + expect(ok.startedAt).toEqual({ value: "2019-03-04T20:15:00Z", precision: "day" }); + expect(ok.finishedAt).toBeNull(); + }); + + test("updateEngagement touches only patched keys and writes history", async () => { + const { db, userId, entry } = await setup(); + const eg = await addEngagement(db, userId, entry.id, { rating: 70, notes: "great" }, "user"); + const up = await updateEngagement(db, userId, eg.id, { rating: 90, progress: { page: 120 } }, "plugin:hardcover:c1"); + expect(up.notes).toBe("great"); + expect(up.rating).toBe(90); + expect(up.progress).toEqual({ page: 120 }); + expect(up.fieldSources).toEqual({ rating: "plugin:hardcover:c1", notes: "user", progress: "plugin:hardcover:c1" }); + const [h] = await getHistory(db, userId, "engagement", eg.id); + expect(h!.before).toEqual({ rating: 70, progress: {} }); + expect(h!.after).toEqual({ rating: 90, progress: { page: 120 } }); + await restoreVersion(db, userId, h!.id); + const [back] = await listEngagements(db, userId, entry.id); + expect(back!.rating).toBe(70); + expect(back!.fieldSources.rating).toBe("user"); + await expect(updateEngagement(db, userId, eg.id, { rating: -1 }, "user")).rejects.toThrow(ValidationError); + await expect(updateEngagement(db, userId, eg.id, { bogus: 1 } as never, "user")).rejects.toThrow(ValidationError); + }); + + test("engagement custom fields use engagement-level definitions", async () => { + const { db, userId, entry } = await setup(); + const entryLevel = await defineCustomField(db, userId, { name: "Mood", type: "text" }); + const def = await defineCustomField(db, userId, { name: "Format", type: "single_select", options: ["audio"], level: "engagement" }); + const eg = await addEngagement(db, userId, entry.id, { customFields: { [def.id]: "audio" } }, "user"); + expect(eg.customFields).toEqual({ [def.id]: "audio" }); + await expect( + addEngagement(db, userId, entry.id, { customFields: { [entryLevel.id]: "x" } }, "user"), + ).rejects.toThrow(ValidationError); + }); + + test("softDeleteEngagement hides it; restore brings it back", async () => { + const { db, userId, entry } = await setup(); + const eg = await addEngagement(db, userId, entry.id, { platform: "Switch" }, "user"); + await softDeleteEngagement(db, userId, eg.id, "user"); + expect(await listEngagements(db, userId, entry.id)).toEqual([]); + await expect(updateEngagement(db, userId, eg.id, { platform: "PC" }, "user")).rejects.toThrow(NotFoundError); + const [del] = await getHistory(db, userId, "engagement", eg.id); + await restoreVersion(db, userId, del!.id); + expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([eg.id]); + }); + + test("engagements sort by start date, undated last", async () => { + const { db, userId, entry } = await setup(); + const undated = await addEngagement(db, userId, entry.id, {}, "user"); + const later = await addEngagement(db, userId, entry.id, { startedAt: { value: "2021-05", precision: "month" } }, "user"); + const early = await addEngagement(db, userId, entry.id, { startedAt: { value: "2019", precision: "year" } }, "user"); + expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([early.id, later.id, undated.id]); + }); + + test("another user's engagements are not found", async () => { + const { db, userId, entry } = await setup(); + const bob = await makeUser(db, "bob"); + const eg = await addEngagement(db, userId, entry.id, {}, "user"); + await expect(addEngagement(db, bob, entry.id, {}, "user")).rejects.toThrow(NotFoundError); + await expect(updateEngagement(db, bob, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError); + await expect(softDeleteEngagement(db, bob, eg.id, "user")).rejects.toThrow(NotFoundError); + await expect(listEngagements(db, bob, entry.id)).rejects.toThrow(NotFoundError); + }); +}); diff --git a/packages/core/test/lists.test.ts b/packages/core/test/lists.test.ts new file mode 100644 index 0000000..5cba41f --- /dev/null +++ b/packages/core/test/lists.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { getHistory, restoreVersion } from "../src/history.ts"; +import { + addListItem, + createList, + getList, + listLists, + moveListItem, + removeListItem, + softDeleteList, + updateList, +} from "../src/lists.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("lists", (getDb) => { + test("grouping list can be rated", async () => { + const db = getDb(); + const userId = await makeUser(db); + const g = await createList(db, userId, { name: "Silent Hill, everything", kind: "grouping", rating: 90, review: "Dread." }); + expect(g.kind).toBe("grouping"); + expect(g.rating).toBe(90); + expect(g.review).toBe("Dread."); + expect(g.visibility).toBe("private"); + const up = await updateList(db, userId, g.id, { rating: 95 }); + expect(up.rating).toBe(95); + + await expect(createList(db, userId, { name: "Plain", kind: "list", rating: 50 })).rejects.toThrow(ValidationError); + await expect(createList(db, userId, { name: "Bad", kind: "grouping", rating: 101 })).rejects.toThrow( + ValidationError, + ); + await expect(updateList(db, userId, g.id, { kind: "thread" })).rejects.toThrow(ValidationError); + await expect(createList(db, userId, { name: " " })).rejects.toThrow(ValidationError); + await expect(createList(db, userId, { name: "x", kind: "box" as never })).rejects.toThrow(ValidationError); + }); + + test("items keep order through add, move and remove", async () => { + const db = getDb(); + const userId = await makeUser(db); + const l = await createList(db, userId, { name: "Cosmic dread", kind: "thread" }); + const [a, b, c] = [await makeWork(db, { title: "A" }), await makeWork(db, { title: "B" }), await makeWork(db, { title: "C" })]; + const ia = await addListItem(db, userId, l.id, { workId: a, note: "start here" }); + const ib = await addListItem(db, userId, l.id, { workId: b }); + const ic = await addListItem(db, userId, l.id, { workId: c }); + const titles = async () => (await getList(db, userId, l.id)).items.map((i) => i.work.title); + expect(await titles()).toEqual(["A", "B", "C"]); + expect((await getList(db, userId, l.id)).items[0]!.note).toBe("start here"); + + await moveListItem(db, userId, ic.id, 0); + expect(await titles()).toEqual(["C", "A", "B"]); + await moveListItem(db, userId, ic.id, 99); + expect(await titles()).toEqual(["A", "B", "C"]); + await removeListItem(db, userId, ib.id); + expect(await titles()).toEqual(["A", "C"]); + expect((await getList(db, userId, l.id)).items.map((i) => i.position)).toEqual([0, 1]); + + const inserted = await addListItem(db, userId, l.id, { workId: b, position: 1 }); + expect(await titles()).toEqual(["A", "B", "C"]); + expect(inserted.position).toBe(1); + expect(ia.position).toBe(0); + }); + + test("removing an item is recorded and restorable", async () => { + const db = getDb(); + const userId = await makeUser(db); + const l = await createList(db, userId, { name: "Reading order" }); + await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "A" }) }); + const ib = await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "B" }) }); + await removeListItem(db, userId, ib.id); + const [h] = await getHistory(db, userId, "list", l.id); + await restoreVersion(db, userId, h!.id); + expect((await getList(db, userId, l.id)).items.map((i) => i.work.title)).toEqual(["A", "B"]); + }); + + test("listLists and soft delete", async () => { + const db = getDb(); + const userId = await makeUser(db); + const l1 = await createList(db, userId, { name: "One" }); + const l2 = await createList(db, userId, { name: "Two", kind: "grouping" }); + expect((await listLists(db, userId)).map((l) => l.id).sort()).toEqual([l1.id, l2.id].sort()); + expect((await listLists(db, userId, { kind: "grouping" })).map((l) => l.id)).toEqual([l2.id]); + await softDeleteList(db, userId, l1.id); + expect((await listLists(db, userId)).map((l) => l.id)).toEqual([l2.id]); + await expect(getList(db, userId, l1.id)).rejects.toThrow(NotFoundError); + }); + + test("another user's lists and private works are not found", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const l = await createList(db, ada, { name: "Mine" }); + const item = await addListItem(db, ada, l.id, { workId: await makeWork(db) }); + await expect(getList(db, bob, l.id)).rejects.toThrow(NotFoundError); + await expect(updateList(db, bob, l.id, { name: "x" })).rejects.toThrow(NotFoundError); + await expect(addListItem(db, bob, l.id, { workId: await makeWork(db) })).rejects.toThrow(NotFoundError); + await expect(moveListItem(db, bob, item.id, 0)).rejects.toThrow(NotFoundError); + await expect(removeListItem(db, bob, item.id)).rejects.toThrow(NotFoundError); + expect(await listLists(db, bob)).toEqual([]); + const adaPrivate = await makeWork(db, { ownerUserId: ada }); + const bobList = await createList(db, bob, { name: "Bob's" }); + await expect(addListItem(db, bob, bobList.id, { workId: adaPrivate })).rejects.toThrow(NotFoundError); + }); +});