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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 19:35:15 +01:00
co-authored by Claude Opus 5.5
parent 13b81b1cd5
commit c7471f7761
7 changed files with 1209 additions and 0 deletions
+247
View File
@@ -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<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);
}
+263
View File
@@ -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<string, number | string>;
platform: string | null;
rating: number | null;
notes: string | null;
customFields: Record<string, unknown>;
}
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<Record<string, number | string>>(r.progress, {}),
platform: r.platform,
rating: r.rating,
notes: r.notes,
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
fieldSources: fromJson<FieldSources>(r.field_sources, {}),
createdAt: r.created_at,
updatedAt: r.updated_at,
deletedAt: r.deleted_at,
};
}
function snapshot(e: Engagement): Record<string, unknown> {
const s: Record<string, unknown> = { 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<EngagementPatch>,
): Promise<Partial<Row>> {
checkKeys("patch", patch, ENGAGEMENT_PATCH_KEYS);
const c: Partial<Row> = {};
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<string, unknown>);
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<EngagementPatch>,
actor: Actor,
extra: Partial<Pick<Row, "deleted_at">> = {},
): Promise<Engagement> {
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<EngagementPatch>,
actor: Actor,
tx?: Executor,
): Promise<Engagement> {
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<EngagementPatch>,
actor: Actor,
tx?: Executor,
): Promise<Engagement> {
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<void> {
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<Engagement[]> {
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<string, unknown>,
): Promise<void> {
const { eg, mediaType } = await load(k, userId, engagementId, { includeDeleted: true });
const patch: Record<string, unknown> = {};
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<EngagementPatch>, "user", extra);
}
+9
View File
@@ -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:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
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");
}
+384
View File
@@ -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<Record<string, unknown>> {
const s: Record<string, unknown> = { 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<ListPatch>): Partial<ListPatch> {
checkKeys("patch", patch, LIST_KEYS);
const out: Partial<ListPatch> = {};
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<ListPatch, "kind" | "rating" | "review">): 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<ListPatch>): Partial<Row> {
const c: Partial<Row> = {};
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<List> {
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<ListItem[]> {
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<void> {
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<T>(
k: Executor,
userId: string,
listId: string,
actor: Actor,
change: (l: List) => Promise<T>,
opts: { includeDeleted?: boolean } = {},
): Promise<T> {
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<ListPatch> & { name: string },
actor: Actor = "user",
tx?: Executor,
): Promise<List> {
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<ListPatch>,
actor: Actor = "user",
tx?: Executor,
): Promise<List> {
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<void> {
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<ListItem> {
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<ListItem> {
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<void> {
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<void> {
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<ListWithItems> {
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<List[]> {
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<string, unknown>,
): Promise<void> {
const patch: Record<string, unknown> = {};
for (const key of LIST_KEYS) if (key in before) patch[key] = before[key];
const p = normalise(patch as Partial<ListPatch>);
await mutate(
k,
userId,
listId,
"user",
async (l) => {
checkGrouping({ ...l, ...p });
const cols: Partial<Row> = 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 },
);
}
+76
View File
@@ -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,
);
});
});
+126
View File
@@ -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);
});
});
+104
View File
@@ -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);
});
});