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:
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 },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user