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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
13b81b1cd5
commit
c7471f7761
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user