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, EntryStatus, Visibility } from "@sttracker/db/schema"; import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date"; import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types"; import { customFieldsFor, validateCustomValues } from "./custom-fields.ts"; import { NotFoundError, ValidationError } from "./errors.ts"; import { type Actor, type Executor, inTx, recordHistory } from "./history.ts"; // --------------------------------------------------------------------------------------------- // Shared user-layer helpers (also used by engagements, lists and copies). // --------------------------------------------------------------------------------------------- export const STATUSES: readonly EntryStatus[] = ["planned", "in_progress", "paused", "completed", "dropped", "none"]; export const VISIBILITIES: readonly Visibility[] = ["private", "friends", "public"]; /** Built-in media types. Works always carry one; user-defined types live in `entries.media_type_override`. */ export const MEDIA_TYPES = [ "book", "comic", "manga", "game", "movie", "show", "season", "episode", "podcast", "podcast_episode", "audio_drama", "artist", "album", "track", "video", "other", ] as const satisfies readonly MediaType[]; const PRECISIONS = ["day", "month", "year", "unknown"] as const; /** A map from patched field name to the actor that last set it. The sync engine reads this. */ export type FieldSources = Record; /** Throw unless `v` is null or an integer 0–100. */ export function checkRating(field: string, v: unknown): number | null { if (v === null) return null; if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 100) { throw new ValidationError(field, "out_of_range"); } return v; } /** Throw unless `v` is null or a string. */ export function checkText(field: string, v: unknown): string | null { if (v === null) return null; if (typeof v !== "string") throw new ValidationError(field, "not_text"); return v; } /** Throw on any key not in `allowed`. */ export function checkKeys(field: string, obj: object, allowed: readonly string[]): void { if (obj === null || typeof obj !== "object" || Array.isArray(obj)) throw new ValidationError(field, "not_object"); for (const k of Object.keys(obj)) { if (!allowed.includes(k)) throw new ValidationError(k, "unknown_field"); } } /** * Validate a partial date and convert it to its stored pair. `null` (or precision "unknown") * stores `{ value: null, precision: "unknown" }`. Never goes through `Date`. */ export function toStoredDate(field: string, d: unknown): { value: string | null; precision: string } { if (d === null) return { value: null, precision: "unknown" }; if (typeof d !== "object" || Array.isArray(d)) throw new ValidationError(field, "not_date"); const { value, precision } = d as PartialDate; if (!PRECISIONS.includes(precision)) throw new ValidationError(field, "bad_precision"); if (precision === "unknown") { if (value !== "" && value != null) throw new ValidationError(field, "bad_precision"); return { value: null, precision: "unknown" }; } const parsed = typeof value === "string" ? parsePartialDate(value) : null; if (!parsed || parsed.value !== value || parsed.precision !== precision) { throw new ValidationError(field, "not_date"); } return { value, precision }; } /** Read a stored date pair back; a null value is a null date. */ export function fromStoredDate(value: string | null, precision: string): PartialDate | null { return value === null ? null : { value, precision: precision as PartialDate["precision"] }; } /** The user's default visibility preference (`preferences.defaultVisibility`), else "private". */ export async function defaultVisibility(k: Executor, userId: string): Promise { const u = await k.selectFrom("users").select("preferences").where("id", "=", userId).executeTakeFirst(); const v = fromJson<{ defaultVisibility?: unknown }>(u?.preferences, {}).defaultVisibility; return VISIBILITIES.includes(v as Visibility) ? (v as Visibility) : "private"; } /** * Follow the user's `work_aliases` and global `merged_into_id` tombstones from `workId`, then check * the result is visible to the user (shared, or their own private work). Throws `NotFoundError`. */ export async function resolveVisibleWork( k: Executor, userId: string, workId: string, ): Promise<{ id: string; media_type: string; owner_user_id: string | null }> { let id = workId; for (let hop = 0; hop < 16; hop++) { const alias = await k .selectFrom("work_aliases") .select("to_work_id") .where("user_id", "=", userId) .where("from_work_id", "=", id) .executeTakeFirst(); if (alias) { id = alias.to_work_id; continue; } const w = await k .selectFrom("works") .select(["id", "media_type", "owner_user_id", "merged_into_id"]) .where("id", "=", id) .executeTakeFirst(); if (!w || (w.owner_user_id !== null && w.owner_user_id !== userId)) throw new NotFoundError("work"); if (w.merged_into_id) { id = w.merged_into_id; continue; } return w; } throw new NotFoundError("work"); } // --------------------------------------------------------------------------------------------- // Entries // --------------------------------------------------------------------------------------------- export interface EntryOverrides { title?: string; coverUrl?: string; year?: number | null; } const OVERRIDE_KEYS = ["title", "coverUrl", "year"] as const; export interface EntryPatch { status: EntryStatus; rating: number | null; review: string | null; privateNotes: string | null; favorite: boolean; tags: string[]; customFields: Record; visibility: Visibility; overrides: EntryOverrides; mediaTypeOverride: string | null; } export const ENTRY_PATCH_KEYS = [ "status", "rating", "review", "privateNotes", "favorite", "tags", "customFields", "visibility", "overrides", "mediaTypeOverride", ] as const satisfies readonly (keyof EntryPatch)[]; export interface Entry extends EntryPatch { id: string; userId: string; workId: string; fieldSources: FieldSources; createdAt: string; updatedAt: string; deletedAt: string | null; } export interface EntryView extends Entry { /** The work as this user sees it (title, cover and year overlaid with `overrides`). */ work: { id: string; mediaType: string; title: string; originalTitle: string; year: number | null; coverUrl: string; parentId: string | null; }; /** The work has no external IDs. */ draft: boolean; /** Non-deleted child entries (by work `parent_id`), or null when there are none. */ childSummary: { total: number; completed: number } | null; } type EntryRow = Database["entries"]; function toEntry(r: EntryRow): Entry { return { id: r.id, userId: r.user_id, workId: r.work_id, status: r.status, rating: r.rating, review: r.review, privateNotes: r.private_notes, favorite: Boolean(r.favorite), tags: fromJson(r.tags, []), customFields: fromJson>(r.custom_fields, {}), visibility: r.visibility, overrides: fromJson(r.overrides, {}), mediaTypeOverride: r.media_type_override, fieldSources: fromJson(r.field_sources, {}), createdAt: r.created_at, updatedAt: r.updated_at, deletedAt: r.deleted_at, }; } /** The history snapshot of an entry: its patchable fields plus `workId` and `deletedAt`. */ function snapshot(e: Entry): Record { const s: Record = { workId: e.workId, deletedAt: e.deletedAt }; for (const k of ENTRY_PATCH_KEYS) s[k] = e[k]; return s; } function patchToColumns(p: Partial): Partial { const c: Partial = {}; if ("status" in p) c.status = p.status!; if ("rating" in p) c.rating = p.rating!; if ("review" in p) c.review = p.review!; if ("privateNotes" in p) c.private_notes = p.privateNotes!; if ("favorite" in p) c.favorite = p.favorite ? 1 : 0; if ("tags" in p) c.tags = toJson(p.tags); if ("customFields" in p) c.custom_fields = toJson(p.customFields); if ("visibility" in p) c.visibility = p.visibility!; if ("overrides" in p) c.overrides = toJson(p.overrides); if ("mediaTypeOverride" in p) c.media_type_override = p.mediaTypeOverride!; return c; } /** Validate and normalise a patch. Custom fields are checked against the user's entry-level definitions. */ async function normalisePatch( k: Executor, userId: string, mediaType: string, patch: Partial, opts: { trustCustomFields?: boolean } = {}, ): Promise> { checkKeys("patch", patch, ENTRY_PATCH_KEYS); const out: Partial = {}; if ("status" in patch) { if (!STATUSES.includes(patch.status as EntryStatus)) throw new ValidationError("status", "invalid"); out.status = patch.status; } if ("rating" in patch) out.rating = checkRating("rating", patch.rating); if ("review" in patch) out.review = checkText("review", patch.review); if ("privateNotes" in patch) out.privateNotes = checkText("privateNotes", patch.privateNotes); if ("favorite" in patch) { if (typeof patch.favorite !== "boolean") throw new ValidationError("favorite", "not_boolean"); out.favorite = patch.favorite; } if ("tags" in patch) { if (!Array.isArray(patch.tags) || !patch.tags.every((t) => typeof t === "string")) { throw new ValidationError("tags", "not_list"); } out.tags = [...new Set(patch.tags.map((t) => t.trim()).filter(Boolean))]; } if ("visibility" in patch) { if (!VISIBILITIES.includes(patch.visibility as Visibility)) throw new ValidationError("visibility", "invalid"); out.visibility = patch.visibility; } if ("mediaTypeOverride" in patch) { const m = checkText("mediaTypeOverride", patch.mediaTypeOverride); out.mediaTypeOverride = m?.trim() || null; } if ("overrides" in patch) out.overrides = checkOverrides(patch.overrides); if ("customFields" in patch) { const cf = patch.customFields; if (cf === null || typeof cf !== "object" || Array.isArray(cf)) throw new ValidationError("customFields", "not_object"); if (!opts.trustCustomFields) { const effective = out.mediaTypeOverride ?? mediaType; const defs = await customFieldsFor(k, userId, { mediaType: effective, level: "entry" }); validateCustomValues(defs, cf); } out.customFields = cf; } return out; } function checkOverrides(o: unknown): EntryOverrides { checkKeys("overrides", o as object, OVERRIDE_KEYS); const v = o as Record; const out: EntryOverrides = {}; if (v.title !== undefined) { if (typeof v.title !== "string" || !v.title.trim()) throw new ValidationError("overrides.title", "not_text"); out.title = v.title.trim(); } if (v.coverUrl !== undefined) { if (typeof v.coverUrl !== "string") throw new ValidationError("overrides.coverUrl", "not_text"); out.coverUrl = v.coverUrl; } if (v.year !== undefined) { if (v.year !== null && (typeof v.year !== "number" || !Number.isInteger(v.year))) { throw new ValidationError("overrides.year", "not_integer"); } out.year = v.year; } return out; } interface WriteOpts { /** Non-patch columns. */ extra?: Partial>; /** Per-key `field_sources` for patched keys instead of `actor`; `undefined` removes the key. */ sources?: Record; /** History `before` override: `null` records a creation. */ historyBefore?: Record | null; /** Extra keys merged into the history row's before/after snapshots. */ historyExtra?: { before: Record; after: Record }; } /** * Write a patch to an entry, set `field_sources[key] = actor` for each patched key (unless * `opts.sources` says otherwise), and record history. */ async function writeEntry( k: Executor, current: Entry, patch: Partial, actor: Actor, opts: WriteOpts = {}, ): Promise { const fieldSources: Record = { ...current.fieldSources }; for (const key of Object.keys(patch)) { fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor; if (fieldSources[key] === undefined) delete fieldSources[key]; } const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() }; await k.updateTable("entries").set(cols).where("id", "=", current.id).execute(); const after = await loadEntry(k, current.userId, current.id, { includeDeleted: true }); const before = opts.historyBefore === undefined ? snapshot(current) : opts.historyBefore; await recordHistory(k, { userId: current.userId, entity: "entry", entityId: current.id, before: before === null ? null : { ...before, ...opts.historyExtra?.before }, after: { ...snapshot(after), ...opts.historyExtra?.after }, actor, }); return after; } async function loadEntry( k: Executor, userId: string, entryId: string, opts: { includeDeleted?: boolean } = {}, ): Promise { let q = k.selectFrom("entries").selectAll().where("id", "=", entryId).where("user_id", "=", userId); if (!opts.includeDeleted) q = q.where("deleted_at", "is", null); const r = await q.executeTakeFirst(); if (!r) throw new NotFoundError("entry"); return toEntry(r); } /** The user's entry (live or not) for a work, if any. */ async function findEntry(k: Executor, userId: string, workId: string): Promise { const r = await k .selectFrom("entries") .selectAll() .where("user_id", "=", userId) .where("work_id", "=", workId) .executeTakeFirst(); return r ? toEntry(r) : null; } /** Verify the entry belongs to the user and is live; returns its work's media type. Used by engagements. */ export async function requireEntry(k: Executor, userId: string, entryId: string): Promise<{ mediaType: string }> { const r = await k .selectFrom("entries as e") .innerJoin("works as w", "w.id", "e.work_id") .select(["e.media_type_override", "w.media_type"]) .where("e.id", "=", entryId) .where("e.user_id", "=", userId) .where("e.deleted_at", "is", null) .executeTakeFirst(); if (!r) throw new NotFoundError("entry"); return { mediaType: r.media_type_override ?? r.media_type }; } /** * Create or update the user's entry for a work. Only the keys present in `patch` are touched, and * each gets `field_sources[key] = actor`. A soft-deleted entry is revived. Aliased or merged works * resolve to their target. Writes history. */ export async function upsertEntry( db: Db, userId: string, workId: string, patch: Partial, actor: Actor, tx?: Executor, ): Promise { return inTx(db, tx, async (k) => { const work = await resolveVisibleWork(k, userId, workId); let existing = await findEntry(k, userId, work.id); const p = await normalisePatch(k, userId, existing?.mediaTypeOverride ?? work.media_type, patch); let created = false; if (!existing) { // Insert-or-ignore, then patch through the update path: a concurrent creator never aborts our // transaction (Postgres poisons a transaction on a unique violation). const now = nowIso(); const id = newId(); await k .insertInto("entries") .values({ id, user_id: userId, work_id: work.id, status: "none", rating: null, review: null, private_notes: null, favorite: 0, tags: toJson([]), custom_fields: toJson({}), visibility: await defaultVisibility(k, userId), overrides: toJson({}), media_type_override: null, field_sources: toJson({}), created_at: now, updated_at: now, deleted_at: null, }) .onConflict((oc) => oc.columns(["user_id", "work_id"]).doNothing()) .execute(); existing = (await findEntry(k, userId, work.id))!; created = existing.id === id; } if (!created && Object.keys(p).length === 0 && existing.deletedAt === null) return existing; return writeEntry(k, existing, p, actor, { extra: existing.deletedAt === null ? {} : { deleted_at: null }, historyBefore: created ? null : undefined, }); }); } /** Soft-delete the user's entry. Writes history. */ export async function softDeleteEntry( db: Db, userId: string, entryId: string, actor: Actor, tx?: Executor, ): Promise { await inTx(db, tx, async (k) => { const e = await loadEntry(k, userId, entryId); await writeEntry(k, e, {}, actor, { extra: { deleted_at: nowIso() } }); }); } /** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */ export async function restoreEntryFromHistory( k: Executor, userId: string, entryId: string, before: Record, ): Promise { const current = await loadEntry(k, userId, entryId, { includeDeleted: true }); const patch: Record = {}; for (const key of ENTRY_PATCH_KEYS) if (key in before) patch[key] = before[key]; if (typeof before.workId === "string" && before.workId !== current.workId) { // Undo a move: drop the forward alias and un-tombstone the user's own private work, then move back // without writing a reverse alias (later syncs onto the target must not land on the old work). await k .deleteFrom("work_aliases") .where("user_id", "=", userId) .where("from_work_id", "=", before.workId) .where("to_work_id", "=", current.workId) .execute(); await k .updateTable("works") .set({ merged_into_id: null, updated_at: nowIso() }) .where("id", "=", before.workId) .where("owner_user_id", "=", userId) .execute(); await moveEntry(k, userId, entryId, before.workId, "user", { undo: true }); } const fresh = await loadEntry(k, userId, entryId, { includeDeleted: true }); const work = await k.selectFrom("works").select("media_type").where("id", "=", fresh.workId).executeTakeFirstOrThrow(); // The user's own past data: custom values are not re-checked against today's definitions. const p = await normalisePatch(k, userId, fresh.mediaTypeOverride ?? work.media_type, patch as Partial, { trustCustomFields: true, }); const extra = "deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {}; await writeEntry(k, fresh, p, "user", { extra }); } const isEmpty = (v: unknown) => v === null || v === false || v === "none" || v === "" || (Array.isArray(v) && v.length === 0) || (typeof v === "object" && v !== null && Object.keys(v).length === 0); /** Separator used when two entries' review or private notes are combined by a merge. */ export const MERGE_TEXT_SEPARATOR = "\n\n---\n\n"; /** * Move the user's entry to another work (per-user merge, spec §6.1). See `moveEntry`. * Returns the surviving entry. */ export async function moveEntryToWork( db: Db, userId: string, entryId: string, targetWorkId: string, actor: Actor, tx?: Executor, ): Promise { return inTx(db, tx, (k) => moveEntry(k, userId, entryId, targetWorkId, actor)); } /** The field_sources value for a combined field: "user" if either side was the user's, else the destination's. */ function combinedSource(src: Entry, dst: Entry, key: string): Actor | undefined { return src.fieldSources[key] === "user" || dst.fieldSources[key] === "user" ? "user" : dst.fieldSources[key]; } /** * Move an entry to `targetWorkId`. * - No entry on the target: the entry's `work_id` changes. * - A live entry on the target: merge into it. Its values win; empty fields are filled from the moved * entry; tags/custom fields/overrides are combined; differing review/private notes are concatenated; * a conflicting rating is kept but the moved rating is recorded in the history row's `before`. * Engagements are re-parented (with history) and the moved entry is soft-deleted. * - A soft-deleted entry on the target: revived with the moved entry's values and field_sources. * Then (unless `undo`): this user's copies and list items follow to the target, a `work_aliases` row * maps the old work to the target, and the user's own private draft is tombstoned into the target. */ async function moveEntry( k: Executor, userId: string, entryId: string, targetWorkId: string, actor: Actor, opts: { undo?: boolean } = {}, ): Promise { const src = await loadEntry(k, userId, entryId); const target = await resolveVisibleWork(k, userId, targetWorkId); if (target.id === src.workId) throw new ValidationError("targetWorkId", "same_work"); const dst = await findEntry(k, userId, target.id); let result: Entry; if (!dst) { result = await writeEntry(k, src, {}, actor, { extra: { work_id: target.id } }); } else if (dst.deletedAt !== null) { const all: Partial = {}; for (const key of ENTRY_PATCH_KEYS) (all as Record)[key] = src[key]; const sources: Record = {}; for (const key of ENTRY_PATCH_KEYS) sources[key] = src.fieldSources[key]; result = await writeEntry(k, dst, all, actor, { extra: { deleted_at: null }, sources }); await moveEngagements(k, userId, src.id, dst.id, actor); await writeEntry(k, src, {}, actor, { extra: { deleted_at: nowIso() } }); } else { const merged: Record = {}; const sources: Record = {}; let historyExtra: WriteOpts["historyExtra"]; for (const key of ENTRY_PATCH_KEYS) { const s = src[key]; const d = dst[key]; if (key === "tags") { const tags = [...new Set([...dst.tags, ...src.tags])]; if (tags.length !== dst.tags.length) { merged[key] = tags; sources[key] = combinedSource(src, dst, key); } } else if (key === "customFields" || key === "overrides") { const combined = { ...(s as object), ...(d as object) }; if (Object.keys(combined).length !== Object.keys(d as object).length) { merged[key] = combined; sources[key] = combinedSource(src, dst, key); } } else if (isEmpty(d) && !isEmpty(s)) { merged[key] = s; sources[key] = src.fieldSources[key]; } else if ((key === "review" || key === "privateNotes") && !isEmpty(s) && s !== d) { merged[key] = `${d as string}${MERGE_TEXT_SEPARATOR}${s as string}`; sources[key] = combinedSource(src, dst, key); } else if (key === "rating" && s !== null && s !== d) { historyExtra = { before: { rating: s }, after: { rating: d } }; } } result = await writeEntry(k, dst, merged as Partial, actor, { sources, historyExtra }); await moveEngagements(k, userId, src.id, dst.id, actor); await writeEntry(k, src, {}, actor, { extra: { deleted_at: nowIso() } }); } if (result.id !== src.id) { // The moved entry merged into another one: source records that mapped to it keep syncing into the // survivor. Engagements keep their ids when re-parented, so their mappings stay valid. await k .updateTable("source_records") .set({ target_id: result.id }) .where("target_entity", "=", "entry") .where("target_id", "=", src.id) .execute(); } if (opts.undo) return result; await repointCopiesAndListItems(k, userId, src.workId, target.id, actor); // Aliases: re-point any that led to the old work, drop one leaving the target (no cycles), add ours. await k .updateTable("work_aliases") .set({ to_work_id: target.id }) .where("user_id", "=", userId) .where("to_work_id", "=", src.workId) .execute(); await k.deleteFrom("work_aliases").where("user_id", "=", userId).where("from_work_id", "=", target.id).execute(); const alias = await k .selectFrom("work_aliases") .select("id") .where("user_id", "=", userId) .where("from_work_id", "=", src.workId) .executeTakeFirst(); if (alias) { await k.updateTable("work_aliases").set({ to_work_id: target.id }).where("id", "=", alias.id).execute(); } else { await k .insertInto("work_aliases") .values({ id: newId(), user_id: userId, from_work_id: src.workId, to_work_id: target.id }) .execute(); } // A private draft stays private; it is tombstoned and points to the target (spec §6.1). await k .updateTable("works") .set({ merged_into_id: target.id, updated_at: nowIso() }) .where("id", "=", src.workId) .where("owner_user_id", "=", userId) .execute(); return result; } /** Re-parent an entry's live and deleted engagements, writing an `entryId` history row for each. */ async function moveEngagements(k: Executor, userId: string, fromEntryId: string, toEntryId: string, actor: Actor) { const rows = await k.selectFrom("engagements").select("id").where("entry_id", "=", fromEntryId).execute(); for (const { id } of rows) { await k.updateTable("engagements").set({ entry_id: toEntryId, updated_at: nowIso() }).where("id", "=", id).execute(); await recordHistory(k, { userId, entity: "engagement", entityId: id, before: { entryId: fromEntryId }, after: { entryId: toEntryId }, actor, }); } } /** Point this user's copies (with history) and the items of this user's lists at the new work. */ async function repointCopiesAndListItems(k: Executor, userId: string, fromWorkId: string, toWorkId: string, actor: Actor) { const copies = await k .selectFrom("copies") .select("id") .where("user_id", "=", userId) .where("work_id", "=", fromWorkId) .execute(); for (const { id } of copies) { await k.updateTable("copies").set({ work_id: toWorkId, updated_at: nowIso() }).where("id", "=", id).execute(); await recordHistory(k, { userId, entity: "copy", entityId: id, before: { workId: fromWorkId }, after: { workId: toWorkId }, actor, }); } await k .updateTable("list_items") .set({ work_id: toWorkId }) .where("work_id", "=", fromWorkId) .where("list_id", "in", k.selectFrom("lists").select("id").where("user_id", "=", userId)) .execute(); } /** Insert a private draft work owned by the user (no external IDs). Returns its id. */ async function insertPrivateWork(k: Executor, userId: string, title: string, mediaType: string): Promise { const id = newId(); const now = nowIso(); await k .insertInto("works") .values({ id, media_type: mediaType, title, original_title: "", year: null, release_date: null, creators: toJson([]), description: "", cover_url: "", cover_attachment_id: null, metadata: toJson({}), parent_id: null, position: null, owner_user_id: userId, merged_into_id: null, created_at: now, updated_at: now, }) .execute(); return id; } /** Create a private draft work and the user's entry on it, in one transaction. */ export async function quickAdd( db: Db, userId: string, input: { title: string; mediaType?: MediaType }, ): Promise { const title = typeof input.title === "string" ? input.title.trim() : ""; if (!title) throw new ValidationError("title", "required"); const mediaType = input.mediaType ?? "other"; if (!(MEDIA_TYPES as readonly string[]).includes(mediaType)) throw new ValidationError("mediaType", "invalid"); const entry = await db.k.transaction().execute(async (k) => { const workId = await insertPrivateWork(k, userId, title, mediaType); return upsertEntry(db, userId, workId, {}, "user", k); }); return getEntry(db, userId, entry.id); } // --------------------------------------------------------------------------------------------- // Reading: views and the library list // --------------------------------------------------------------------------------------------- /** * FROM/WHERE for this user's live entries on works below `e.work_id`, down to great-grandchildren * (show → season → episode, series → volume → chapter). Portable: three fixed self-joins. */ const DESCENDANTS = sql`from entries c inner join works cw on cw.id = c.work_id left join works p1 on p1.id = cw.parent_id left join works p2 on p2.id = p1.parent_id where c.user_id = e.user_id and c.deleted_at is null and (cw.parent_id = e.work_id or p1.parent_id = e.work_id or p2.parent_id = e.work_id)`; /** * True when one of the work's ancestors (parent, grandparent, great-grandparent) carries a live entry * of this user, i.e. the entry is rolled up under that ancestor's entry. */ const HAS_ENTRY_ANCESTOR = sql`exists (select 1 from works a1 left join works a2 on a2.id = a1.parent_id left join works a3 on a3.id = a2.parent_id inner join entries pe on pe.user_id = e.user_id and pe.deleted_at is null and (pe.work_id = a1.id or pe.work_id = a2.id or pe.work_id = a3.id) where a1.id = w.parent_id)`; /** * True when the entry's work is a descendant of `parentId` (up to three levels) with no work strictly * between them carrying a live entry of this user: the top-level rule, relative to `parentId`. */ function underParent(parentId: string) { const liveEntryOn = (ids: ReturnType) => sql`exists (select 1 from entries pe where pe.user_id = e.user_id and pe.deleted_at is null and pe.work_id in (${ids}))`; return sql`(w.parent_id = ${parentId} or exists (select 1 from works p1 where p1.id = w.parent_id and p1.parent_id = ${parentId} and not ${liveEntryOn(sql`p1.id`)}) or exists (select 1 from works p1 inner join works p2 on p2.id = p1.parent_id where p1.id = w.parent_id and p2.parent_id = ${parentId} and not ${liveEntryOn(sql`p1.id, p2.id`)}))`; } function viewQuery(k: Executor, userId: string) { return k .selectFrom("entries as e") .innerJoin("works as w", "w.id", "e.work_id") .selectAll("e") .select([ "w.media_type as w_media_type", "w.title as w_title", "w.original_title as w_original_title", "w.year as w_year", "w.cover_url as w_cover_url", "w.parent_id as w_parent_id", sql`case when exists (select 1 from external_ids x where x.work_id = w.id) then 0 else 1 end`.as( "is_draft", ), sql`(select count(*) ${DESCENDANTS})`.as("child_total"), sql`(select count(*) ${DESCENDANTS} and c.status = 'completed')`.as("child_completed"), sql`lower(w.title)`.as("title_key"), ]) .where("e.user_id", "=", userId) .where("e.deleted_at", "is", null); } type ViewRow = EntryRow & { w_media_type: string; w_title: string; w_original_title: string; w_year: number | null; w_cover_url: string; w_parent_id: string | null; is_draft: number | string; child_total: number | string; child_completed: number | string; title_key: string; }; function toView(r: ViewRow): EntryView { const e = toEntry(r); const o = e.overrides; const total = Number(r.child_total); return { ...e, work: { id: r.work_id, mediaType: r.w_media_type, title: o.title ?? r.w_title, originalTitle: r.w_original_title, year: o.year !== undefined ? o.year : r.w_year, coverUrl: o.coverUrl ?? r.w_cover_url, parentId: r.w_parent_id, }, draft: Number(r.is_draft) === 1, childSummary: total > 0 ? { total, completed: Number(r.child_completed) } : null, }; } /** One live entry of the user's, with its work view. */ export async function getEntry(db: Db, userId: string, entryId: string, tx?: Executor): Promise { const r = await viewQuery(tx ?? db.k, userId).where("e.id", "=", entryId).executeTakeFirst(); if (!r) throw new NotFoundError("entry"); return toView(r as ViewRow); } export interface EntryFilter { mediaType?: string; status?: EntryStatus; tag?: string; draft?: boolean; q?: string; /** * Default `null`: the top of the user's library, i.e. entries with no ancestor work (up to three * levels) carrying a live entry of this user. A work id applies the same rule relative to that work: * its descendants (up to three levels) with no live-entry work strictly between them and it. */ parentId?: string | null; sort?: "updated" | "title" | "rating"; limit?: number; cursor?: string; } const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); /** * The user's live entries, keyset-paginated. `cursor` is base64 JSON `{ sort, sortValue, id }`; for the * title sort `sortValue` is the SQL `lower(w.title)` of the last row, so SQL and cursor always agree. * Sorts: `updated` (newest first), `title` (A–Z, case-insensitive), `rating` (highest first, unrated last). */ export async function listEntries( db: Db, userId: string, filter: EntryFilter, ): Promise<{ items: EntryView[]; nextCursor: string | null }> { const sort = filter.sort ?? "updated"; if (!["updated", "title", "rating"].includes(sort)) throw new ValidationError("sort", "invalid"); const limit = filter.limit ?? 50; if (!Number.isInteger(limit) || limit < 1) throw new ValidationError("limit", "invalid"); const take = Math.min(limit, 200); let q = viewQuery(db.k, userId); const parentId = filter.parentId === undefined ? null : filter.parentId; q = q.where(parentId === null ? sql`not ${HAS_ENTRY_ANCESTOR}` : underParent(parentId)); if (filter.mediaType) q = q.where(sql`coalesce(e.media_type_override, w.media_type)`, "=", filter.mediaType); if (filter.status) q = q.where("e.status", "=", filter.status); if (filter.tag) { const needle = `%${escapeLike(JSON.stringify(filter.tag.trim().toLowerCase()))}%`; q = q.where(sql`lower(e.tags) like ${needle} escape '\\'`); } if (filter.draft !== undefined) { const has = sql`exists (select 1 from external_ids x where x.work_id = w.id)`; q = q.where(filter.draft ? sql`not ${has}` : has); } if (filter.q?.trim()) { const p = `%${escapeLike(filter.q.trim().toLowerCase())}%`; q = q.where( sql`(lower(w.title) like ${p} escape '\\' or lower(w.original_title) like ${p} escape '\\')`, ); } const titleKey = sql`lower(w.title)`; const ratingNull = sql`case when e.rating is null then 1 else 0 end`; if (filter.cursor !== undefined) { const c = decodeCursor(filter.cursor, sort); if (sort === "updated") { const v = c.sortValue as string; q = q.where((eb) => eb.or([eb("e.updated_at", "<", v), eb.and([eb("e.updated_at", "=", v), eb("e.id", "<", c.id)])]), ); } else if (sort === "title") { const v = c.sortValue as string; q = q.where((eb) => eb.or([eb(titleKey, ">", v), eb.and([eb(titleKey, "=", v), eb("e.id", ">", c.id)])])); } else if (c.sortValue === null) { q = q.where("e.rating", "is", null).where("e.id", "<", c.id); } else { const v = c.sortValue as number; q = q.where((eb) => eb.or([ eb("e.rating", "is", null), eb("e.rating", "<", v), eb.and([eb("e.rating", "=", v), eb("e.id", "<", c.id)]), ]), ); } } if (sort === "updated") q = q.orderBy("e.updated_at", "desc").orderBy("e.id", "desc"); else if (sort === "title") q = q.orderBy(titleKey, "asc").orderBy("e.id", "asc"); else q = q.orderBy(ratingNull, "asc").orderBy("e.rating", "desc").orderBy("e.id", "desc"); const rows = (await q.limit(take + 1).execute()) as ViewRow[]; const page = rows.slice(0, take); let nextCursor: string | null = null; if (rows.length > take) { const last = page[page.length - 1]!; const sortValue = sort === "updated" ? last.updated_at : sort === "title" ? last.title_key : last.rating; nextCursor = Buffer.from(JSON.stringify({ sort, sortValue, id: last.id })).toString("base64"); } return { items: page.map(toView), nextCursor }; } function decodeCursor(cursor: string, sort: string): { sortValue: string | number | null; id: string } { const c = fromJson<{ sort?: unknown; sortValue?: unknown; id?: unknown } | null>( Buffer.from(cursor, "base64").toString("utf8"), null, ); const ok = c !== null && typeof c === "object" && c.sort === sort && typeof c.id === "string" && (sort === "rating" ? c.sortValue === null || typeof c.sortValue === "number" : typeof c.sortValue === "string"); if (!ok) throw new ValidationError("cursor", "invalid"); return { sortValue: c.sortValue as string | number | null, id: c.id as string }; } // --------------------------------------------------------------------------------------------- // Match suggestions // --------------------------------------------------------------------------------------------- async function loadSuggestion(k: Executor, userId: string, suggestionId: string) { const s = await k .selectFrom("match_suggestions") .selectAll() .where("id", "=", suggestionId) .where("user_id", "=", userId) .executeTakeFirst(); if (!s) throw new NotFoundError("suggestion"); return s; } /** * Accept a match suggestion: move the user's entry from `work_id` to `candidate_work_id` (actor "user"), * mark it accepted and reject the user's other pending suggestions for the same work. */ export async function acceptSuggestion(db: Db, userId: string, suggestionId: string): Promise { await db.k.transaction().execute(async (k) => { const s = await loadSuggestion(k, userId, suggestionId); if (s.status !== "pending") throw new ValidationError("status", "not_pending"); const entry = await findEntry(k, userId, s.work_id); if (entry && entry.deletedAt === null) { await moveEntry(k, userId, entry.id, s.candidate_work_id, "user"); } await k.updateTable("match_suggestions").set({ status: "accepted" }).where("id", "=", s.id).execute(); await k .updateTable("match_suggestions") .set({ status: "rejected" }) .where("user_id", "=", userId) .where("work_id", "=", s.work_id) .where("status", "=", "pending") .execute(); }); } /** Reject a match suggestion. */ export async function rejectSuggestion(db: Db, userId: string, suggestionId: string): Promise { const s = await loadSuggestion(db.k, userId, suggestionId); if (s.status !== "pending") throw new ValidationError("status", "not_pending"); await db.k.updateTable("match_suggestions").set({ status: "rejected" }).where("id", "=", s.id).execute(); } export interface MatchSuggestion { id: string; workId: string; candidateWorkId: string; score: number; status: "pending" | "accepted" | "rejected"; createdAt: string; } /** The user's pending match suggestions for a work, best first. Throws `NotFoundError` if the work is not visible. */ export async function listSuggestions(db: Db, userId: string, workId: string): Promise { await resolveVisibleWork(db.k, userId, workId); const rows = await db.k .selectFrom("match_suggestions") .selectAll() .where("user_id", "=", userId) .where("work_id", "=", workId) .where("status", "=", "pending") .orderBy("score", "desc") .orderBy("id") .execute(); return rows.map((r) => ({ id: r.id, workId: r.work_id, candidateWorkId: r.candidate_work_id, score: r.score, status: r.status, createdAt: r.created_at, })); }