diff --git a/packages/core/src/entries.ts b/packages/core/src/entries.ts new file mode 100644 index 0000000..ec9d8ab --- /dev/null +++ b/packages/core/src/entries.ts @@ -0,0 +1,811 @@ +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, +): 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 effective = out.mediaTypeOverride ?? mediaType; + const defs = await customFieldsFor(k, userId, { mediaType: effective, level: "entry" }); + validateCustomValues(defs, patch.customFields as Record); + out.customFields = patch.customFields; + } + 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; +} + +/** + * Write `cols` to an entry, set `field_sources[key] = actor` for each patched key, and record history. + * `extra` carries non-patch columns (`work_id`, `deleted_at`). + */ +async function writeEntry( + k: Executor, + current: Entry, + patch: Partial, + actor: Actor, + extra: Partial> = {}, + sources?: FieldSources, +): Promise { + const fieldSources = { ...current.fieldSources }; + for (const key of Object.keys(patch)) fieldSources[key] = sources?.[key] ?? actor; + const cols = { ...patchToColumns(patch), ...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 }); + await recordHistory(k, { + userId: current.userId, + entity: "entry", + entityId: current.id, + before: snapshot(current), + after: snapshot(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); + const existing = await findEntry(k, userId, work.id); + const p = await normalisePatch(k, userId, existing?.mediaTypeOverride ?? work.media_type, patch); + if (existing) { + if (Object.keys(p).length === 0 && existing.deletedAt === null) return existing; + return writeEntry(k, existing, p, actor, existing.deletedAt === null ? {} : { deleted_at: null }); + } + const now = nowIso(); + const fieldSources: FieldSources = {}; + for (const key of Object.keys(p)) fieldSources[key] = actor; + const row: EntryRow = { + id: newId(), + 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, + ...patchToColumns(p), + field_sources: toJson(fieldSources), + created_at: now, + updated_at: now, + deleted_at: null, + }; + await k.insertInto("entries").values(row).execute(); + const entry = toEntry(row); + await recordHistory(k, { userId, entity: "entry", entityId: entry.id, before: null, after: snapshot(entry), actor }); + return entry; + }); +} + +/** 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, { 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 user's alias and un-tombstone their own private work, then move back. + await k + .deleteFrom("work_aliases") + .where("user_id", "=", userId) + .where("from_work_id", "=", before.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"); + } + const fresh = await loadEntry(k, userId, entryId, { includeDeleted: true }); + const work = await k.selectFrom("works").select("media_type").where("id", "=", fresh.workId).executeTakeFirstOrThrow(); + const p = await normalisePatch(k, userId, fresh.mediaTypeOverride ?? work.media_type, patch as Partial); + 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" || (Array.isArray(v) && v.length === 0) || + (typeof v === "object" && v !== null && Object.keys(v).length === 0); + +/** + * Move the user's entry to another work (per-user merge, spec §6.1). Leaves a `work_aliases` row so + * old references resolve, and tombstones the user's own private draft work into the target. + * If the user already has an entry on the target, the two merge: the target's values win, gaps are + * filled from the moved entry, tags/custom fields/overrides are combined, engagements are re-parented, + * and the moved entry is soft-deleted. (A soft-deleted target entry is revived with the moved values.) + * 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)); +} + +async function moveEntry(k: Executor, userId: string, entryId: string, targetWorkId: string, actor: Actor): 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, { work_id: target.id }); + } else { + const merged: Partial = {}; + const sources: FieldSources = {}; + const take = (key: K, v: EntryPatch[K]) => { + merged[key] = v; + if (src.fieldSources[key]) sources[key] = src.fieldSources[key]; + }; + for (const key of ENTRY_PATCH_KEYS) { + if (dst.deletedAt !== null) { + take(key, src[key]); + } else if (key === "tags") { + const tags = [...new Set([...dst.tags, ...src.tags])]; + if (tags.length !== dst.tags.length) take(key, tags); + } else if (key === "customFields" || key === "overrides") { + const combined = { ...src[key], ...dst[key] }; + if (Object.keys(combined).length !== Object.keys(dst[key]).length) take(key, combined as never); + } else if (isEmpty(dst[key]) && !isEmpty(src[key])) { + take(key, src[key]); + } + } + result = await writeEntry(k, dst, merged, actor, dst.deletedAt === null ? {} : { deleted_at: null }, sources); + await k.updateTable("engagements").set({ entry_id: dst.id }).where("entry_id", "=", src.id).execute(); + await writeEntry(k, src, {}, actor, { deleted_at: nowIso() }); + } + + // 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; + +} + +/** 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 +// --------------------------------------------------------------------------------------------- + +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(*) from entries c inner join works cw on cw.id = c.work_id + where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id)`.as("child_total"), + sql`(select count(*) from entries c inner join works cw on cw.id = c.work_id + where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id + and c.status = 'completed')`.as("child_completed"), + ]) + .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; +}; + +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`: top-level works only. A work id lists that work's children. */ + 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 }`. + * 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 = parentId === null ? q.where("w.parent_id", "is", null) : q.where("w.parent_id", "=", 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.w_title.toLowerCase() : 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); + await db.k.updateTable("match_suggestions").set({ status: "rejected" }).where("id", "=", s.id).execute(); +} diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts new file mode 100644 index 0000000..094e5d9 --- /dev/null +++ b/packages/core/src/history.ts @@ -0,0 +1,127 @@ +import type { Kysely } 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 { restoreEntryFromHistory } from "./entries.ts"; +import { NotFoundError } from "./errors.ts"; + +/** Who made a change. Plugin actors are `plugin::`; API tokens are `token:`. */ +export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`; + +/** User-layer entities that write `user_history`. */ +export type HistoryEntity = "entry" | "engagement" | "copy" | "list"; + +/** A Kysely handle: the root instance or a transaction. */ +export type Executor = Kysely; + +export interface HistoryRecord { + id: string; + entity: string; + entityId: string; + before: Record; + after: Record; + actor: string; + createdAt: string; +} + +/** + * Run `fn` in the caller's transaction when given, otherwise in a new one. + * Inside `fn` only the passed handle may be used (SQLite deadlocks on `db.k` mid-transaction). + */ +export function inTx(db: Db, tx: Executor | undefined, fn: (tx: Executor) => Promise): Promise { + return tx ? fn(tx) : db.k.transaction().execute((t) => fn(t)); +} + +/** + * Write a `user_history` row holding only the keys whose values differ between `before` and `after` + * (compared as JSON). `null` before/after means the entity did not exist / no longer exists. + * Returns the history id, or null when nothing changed (no row written). + */ +export async function recordHistory( + tx: Executor | Db, + input: { + userId: string; + entity: HistoryEntity; + entityId: string; + before: Record | null; + after: Record | null; + actor: Actor; + }, +): Promise { + const k = "k" in tx ? tx.k : tx; + const before = input.before ?? {}; + const after = input.after ?? {}; + const b: Record = {}; + const a: Record = {}; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (toJson(before[key]) === toJson(after[key])) continue; + if (key in before) b[key] = before[key]; + if (key in after) a[key] = after[key]; + } + if (Object.keys(a).length === 0 && Object.keys(b).length === 0) return null; + const id = newId(); + await k + .insertInto("user_history") + .values({ + id, + user_id: input.userId, + entity: input.entity, + entity_id: input.entityId, + before: toJson(b), + after: toJson(a), + actor: input.actor, + created_at: nowIso(), + }) + .execute(); + return id; +} + +/** The user's history for one entity, newest first. Another user's entity yields `[]`. */ +export async function getHistory(db: Db, userId: string, entity: string, entityId: string): Promise { + const rows = await db.k + .selectFrom("user_history") + .selectAll() + .where("user_id", "=", userId) + .where("entity", "=", entity) + .where("entity_id", "=", entityId) + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .execute(); + return rows.map(toRecord); +} + +/** + * Re-apply a history row's `before` values as actor `user` (which writes a new history row and sets + * `field_sources` for the restored fields to `"user"`). Throws `NotFoundError` for another user's row. + */ +export async function restoreVersion(db: Db, userId: string, historyId: string): Promise { + const row = await db.k + .selectFrom("user_history") + .selectAll() + .where("id", "=", historyId) + .where("user_id", "=", userId) + .executeTakeFirst(); + if (!row) throw new NotFoundError("history"); + const h = toRecord(row); + await db.k.transaction().execute(async (tx) => { + switch (h.entity as HistoryEntity) { + case "entry": + return restoreEntryFromHistory(tx, userId, h.entityId, h.before); + default: + throw new NotFoundError("history"); + } + }); +} + +function toRecord(r: Database["user_history"]): HistoryRecord { + return { + id: r.id, + entity: r.entity, + entityId: r.entity_id, + before: fromJson>(r.before, {}), + after: fromJson>(r.after, {}), + actor: r.actor, + createdAt: r.created_at, + }; +} diff --git a/packages/core/test/entries.test.ts b/packages/core/test/entries.test.ts new file mode 100644 index 0000000..2a547f0 --- /dev/null +++ b/packages/core/test/entries.test.ts @@ -0,0 +1,255 @@ +import { expect, test } from "bun:test"; +import { newId } from "@sttracker/db/ids"; +import { forEachDialect } from "@sttracker/db/testing"; +import { defineCustomField } from "../src/custom-fields.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import { + getEntry, + listEntries, + moveEntryToWork, + quickAdd, + softDeleteEntry, + upsertEntry, +} from "../src/entries.ts"; +import { getHistory } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("entries", (getDb) => { + test("upsertEntry records field_sources and history", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const created = await upsertEntry(db, userId, workId, { status: "planned", tags: ["sci-fi"] }, "user"); + expect(created.status).toBe("planned"); + expect(created.visibility).toBe("private"); + expect(created.fieldSources).toEqual({ status: "user", tags: "user" }); + + const updated = await upsertEntry(db, userId, workId, { rating: 80, status: "completed" }, "plugin:trakt:c1"); + expect(updated.id).toBe(created.id); + expect(updated.rating).toBe(80); + expect(updated.tags).toEqual(["sci-fi"]); + expect(updated.fieldSources).toEqual({ status: "plugin:trakt:c1", tags: "user", rating: "plugin:trakt:c1" }); + + const history = await getHistory(db, userId, "entry", created.id); + expect(history).toHaveLength(2); + expect(history[0]!.before).toEqual({ status: "planned", rating: null }); + expect(history[0]!.after).toEqual({ status: "completed", rating: 80 }); + expect(history[0]!.actor).toBe("plugin:trakt:c1"); + }); + + test("upsertEntry works inside a caller's transaction", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + await expect( + db.k.transaction().execute(async (tx) => { + await upsertEntry(db, userId, workId, { status: "planned" }, "user", tx); + throw new Error("rollback"); + }), + ).rejects.toThrow("rollback"); + expect((await listEntries(db, userId, {})).items).toHaveLength(0); + const e = await db.k.transaction().execute((tx) => upsertEntry(db, userId, workId, { favorite: true }, "user", tx)); + expect(e.favorite).toBe(true); + expect(e.status).toBe("none"); + }); + + test("visibility default comes from user preferences", async () => { + const db = getDb(); + const userId = await makeUser(db, "pub", { defaultVisibility: "public" }); + const e = await upsertEntry(db, userId, await makeWork(db), {}, "user"); + expect(e.visibility).toBe("public"); + expect(e.fieldSources).toEqual({}); + }); + + test("rating 101 rejected", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + await expect(upsertEntry(db, userId, workId, { rating: 101 }, "user")).rejects.toThrow(ValidationError); + await expect(upsertEntry(db, userId, workId, { rating: 7.5 }, "user")).rejects.toThrow(ValidationError); + await expect(upsertEntry(db, userId, workId, { status: "done" as never }, "user")).rejects.toThrow( + ValidationError, + ); + expect((await listEntries(db, userId, {})).items).toHaveLength(0); + }); + + test("custom field values are validated against the user's entry definitions", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db, { mediaType: "book" }); + const def = await defineCustomField(db, userId, { name: "Mood", type: "single_select", options: ["cosy"] }); + const gameOnly = await defineCustomField(db, userId, { name: "Hours", type: "number", appliesTo: ["game"] }); + const e = await upsertEntry(db, userId, workId, { customFields: { [def.id]: "cosy" } }, "user"); + expect(e.customFields).toEqual({ [def.id]: "cosy" }); + await expect(upsertEntry(db, userId, workId, { customFields: { [def.id]: "grim" } }, "user")).rejects.toThrow( + ValidationError, + ); + await expect(upsertEntry(db, userId, workId, { customFields: { [gameOnly.id]: 3 } }, "user")).rejects.toThrow( + ValidationError, + ); + }); + + test("quickAdd creates draft entry", async () => { + const db = getDb(); + const userId = await makeUser(db); + const view = await quickAdd(db, userId, { title: "Zine I found" }); + expect(view.draft).toBe(true); + expect(view.work.title).toBe("Zine I found"); + expect(view.work.mediaType).toBe("other"); + expect(view.status).toBe("none"); + const work = await db.k.selectFrom("works").selectAll().where("id", "=", view.workId).executeTakeFirstOrThrow(); + expect(work.owner_user_id).toBe(userId); + + const shared = await upsertEntry(db, userId, await makeWork(db, { externalIds: { isbn13: "1" } }), {}, "user"); + expect((await getEntry(db, userId, shared.id)).draft).toBe(false); + expect((await listEntries(db, userId, { draft: true })).items.map((i) => i.id)).toEqual([view.id]); + await expect(quickAdd(db, userId, { title: " " })).rejects.toThrow(ValidationError); + await expect(quickAdd(db, userId, { title: "x", mediaType: "zine" as never })).rejects.toThrow(ValidationError); + }); + + test("listEntries rolls up children", async () => { + const db = getDb(); + const userId = await makeUser(db); + const show = await makeWork(db, { title: "Severance", mediaType: "show" }); + const showEntry = await upsertEntry(db, userId, show, { status: "in_progress" }, "user"); + for (const status of ["completed", "completed", "in_progress"] as const) { + const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: show }); + await upsertEntry(db, userId, ep, { status }, "user"); + } + const lone = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), {}, "user"); + + const top = await listEntries(db, userId, {}); + expect(top.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id].sort()); + const showView = top.items.find((i) => i.id === showEntry.id)!; + expect(showView.childSummary).toEqual({ total: 3, completed: 2 }); + expect(top.items.find((i) => i.id === lone.id)!.childSummary).toBeNull(); + + const children = await listEntries(db, userId, { parentId: show }); + expect(children.items).toHaveLength(3); + expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true); + }); + + test("listEntries filters, sorts and paginates", async () => { + const db = getDb(); + const userId = await makeUser(db); + const other = await makeUser(db, "bob"); + const titles = ["Alpha", "bravo", "Charlie", "delta", "Echo"]; + const ids: string[] = []; + for (const [i, title] of titles.entries()) { + const w = await makeWork(db, { title, mediaType: i % 2 ? "game" : "book" }); + const e = await upsertEntry( + db, + userId, + w, + { rating: i === 4 ? null : i * 10, tags: i < 2 ? ["Cosy"] : [], status: i === 0 ? "completed" : "planned" }, + "user", + ); + ids.push(e.id); + await upsertEntry(db, other, w, { status: "completed" }, "user"); + } + // updated sort: newest first, keyset pagination + const p1 = await listEntries(db, userId, { limit: 2 }); + expect(p1.items).toHaveLength(2); + expect(p1.nextCursor).not.toBeNull(); + const p2 = await listEntries(db, userId, { limit: 2, cursor: p1.nextCursor! }); + const p3 = await listEntries(db, userId, { limit: 2, cursor: p2.nextCursor! }); + expect(p3.nextCursor).toBeNull(); + expect([...p1.items, ...p2.items, ...p3.items].map((i) => i.id)).toEqual([...ids].reverse()); + + const byTitle = await listEntries(db, userId, { sort: "title", limit: 3 }); + const byTitle2 = await listEntries(db, userId, { sort: "title", limit: 3, cursor: byTitle.nextCursor! }); + expect([...byTitle.items, ...byTitle2.items].map((i) => i.work.title)).toEqual(titles); + + const byRating = await listEntries(db, userId, { sort: "rating", limit: 2 }); + const byRating2 = await listEntries(db, userId, { sort: "rating", limit: 10, cursor: byRating.nextCursor! }); + expect([...byRating.items, ...byRating2.items].map((i) => i.rating)).toEqual([30, 20, 10, 0, null]); + + expect((await listEntries(db, userId, { mediaType: "game" })).items).toHaveLength(2); + expect((await listEntries(db, userId, { status: "completed" })).items.map((i) => i.id)).toEqual([ids[0]!]); + expect((await listEntries(db, userId, { tag: "cosy" })).items).toHaveLength(2); + expect((await listEntries(db, userId, { q: "RAV" })).items.map((i) => i.id)).toEqual([ids[1]!]); + expect((await listEntries(db, userId, { q: "100%" })).items).toHaveLength(0); + await expect(listEntries(db, userId, { cursor: "garbage" })).rejects.toThrow(ValidationError); + await expect(listEntries(db, userId, { sort: "title", cursor: p1.nextCursor! })).rejects.toThrow( + ValidationError, + ); + }); + + test("overrides apply to the work view", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db, { title: "Dune", year: 1965 }); + const e = await upsertEntry(db, userId, workId, { overrides: { title: "Dune (my edition)", year: 1990 } }, "user"); + const view = await getEntry(db, userId, e.id); + expect(view.work.title).toBe("Dune (my edition)"); + expect(view.work.year).toBe(1990); + expect(view.work.coverUrl).toBe(""); + await expect(upsertEntry(db, userId, workId, { overrides: { colour: "red" } as never }, "user")).rejects.toThrow( + ValidationError, + ); + }); + + test("other users' entries and private works are not found", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const draft = await quickAdd(db, ada, { title: "Secret" }); + await expect(getEntry(db, bob, draft.id)).rejects.toThrow(NotFoundError); + await expect(upsertEntry(db, bob, draft.workId, {}, "user")).rejects.toThrow(NotFoundError); + await expect(softDeleteEntry(db, bob, draft.id, "user")).rejects.toThrow(NotFoundError); + await expect(moveEntryToWork(db, bob, draft.id, await makeWork(db), "user")).rejects.toThrow(NotFoundError); + await expect(getEntry(db, ada, newId())).rejects.toThrow(NotFoundError); + }); + + test("softDeleteEntry hides the entry and upsert revives it", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const e = await upsertEntry(db, userId, workId, { rating: 40 }, "user"); + await softDeleteEntry(db, userId, e.id, "user"); + await expect(getEntry(db, userId, e.id)).rejects.toThrow(NotFoundError); + expect((await listEntries(db, userId, {})).items).toHaveLength(0); + const again = await upsertEntry(db, userId, workId, { status: "planned" }, "user"); + expect(again.id).toBe(e.id); + expect(again.deletedAt).toBeNull(); + expect(again.rating).toBe(40); + }); + + test("moveEntryToWork leaves alias", async () => { + const db = getDb(); + const userId = await makeUser(db); + const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" }); + const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "9780441013593" } }); + const moved = await moveEntryToWork(db, userId, draft.id, shared, "user"); + expect(moved.id).toBe(draft.id); + expect(moved.workId).toBe(shared); + + const alias = await db.k + .selectFrom("work_aliases") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + expect(alias.from_work_id).toBe(draft.workId); + expect(alias.to_work_id).toBe(shared); + const old = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow(); + expect(old.merged_into_id).toBe(shared); + expect((await getEntry(db, userId, draft.id)).draft).toBe(false); + const [h] = await getHistory(db, userId, "entry", draft.id); + expect(h!.after).toEqual({ workId: shared }); + }); + + test("moveEntryToWork merges into an existing entry on the target", async () => { + const db = getDb(); + const userId = await makeUser(db); + const a = await makeWork(db, { title: "A" }); + const b = await makeWork(db, { title: "B" }); + const src = await upsertEntry(db, userId, a, { rating: 70, tags: ["x"] }, "user"); + const dst = await upsertEntry(db, userId, b, { status: "completed", tags: ["y"] }, "user"); + const merged = await moveEntryToWork(db, userId, src.id, b, "user"); + expect(merged.id).toBe(dst.id); + expect(merged.status).toBe("completed"); + expect(merged.rating).toBe(70); + expect(merged.tags.sort()).toEqual(["x", "y"]); + await expect(getEntry(db, userId, src.id)).rejects.toThrow(NotFoundError); + }); +}); diff --git a/packages/core/test/history.test.ts b/packages/core/test/history.test.ts new file mode 100644 index 0000000..78e9930 --- /dev/null +++ b/packages/core/test/history.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { NotFoundError } from "../src/errors.ts"; +import { getEntry, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "../src/entries.ts"; +import { getHistory, recordHistory, restoreVersion } from "../src/history.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +forEachDialect("history", (getDb) => { + test("recordHistory stores only the changed keys", async () => { + const db = getDb(); + const userId = await makeUser(db); + const id = await recordHistory(db.k, { + userId, + entity: "entry", + entityId: "e1", + before: { status: "planned", rating: 50, tags: ["a"] }, + after: { status: "completed", rating: 50, tags: ["a"] }, + actor: "user", + }); + expect(id).not.toBeNull(); + const none = await recordHistory(db.k, { + userId, + entity: "entry", + entityId: "e1", + before: { rating: 1 }, + after: { rating: 1 }, + actor: "user", + }); + expect(none).toBeNull(); + const rows = await getHistory(db, userId, "entry", "e1"); + expect(rows).toHaveLength(1); + expect(rows[0]!.before).toEqual({ status: "planned" }); + expect(rows[0]!.after).toEqual({ status: "completed" }); + expect(rows[0]!.actor).toBe("user"); + }); + + test("restoreVersion brings back previous review", async () => { + const db = getDb(); + const userId = await makeUser(db); + const workId = await makeWork(db); + const e = await upsertEntry(db, userId, workId, { review: "first take" }, "user"); + await upsertEntry(db, userId, workId, { review: "second take", rating: 60 }, "plugin:trakt:c1"); + + const history = await getHistory(db, userId, "entry", e.id); + expect(history).toHaveLength(2); + const latest = history[0]!; // newest first + expect(latest.before).toEqual({ review: "first take", rating: null }); + expect(latest.actor).toBe("plugin:trakt:c1"); + + await restoreVersion(db, userId, latest.id); + const view = await getEntry(db, userId, e.id); + expect(view.review).toBe("first take"); + expect(view.rating).toBeNull(); + expect(view.fieldSources.review).toBe("user"); + expect(view.fieldSources.rating).toBe("user"); + expect(await getHistory(db, userId, "entry", e.id)).toHaveLength(3); + }); + + test("restoreVersion undoes a soft delete", async () => { + const db = getDb(); + const userId = await makeUser(db); + const e = await upsertEntry(db, userId, await makeWork(db), { status: "planned" }, "user"); + await softDeleteEntry(db, userId, e.id, "user"); + const [del] = await getHistory(db, userId, "entry", e.id); + expect(del!.after.deletedAt).toBeString(); + await restoreVersion(db, userId, del!.id); + expect((await getEntry(db, userId, e.id)).status).toBe("planned"); + }); + + test("restoreVersion undoes a move back to the draft work", async () => { + const db = getDb(); + const userId = await makeUser(db); + const draft = await quickAdd(db, userId, { title: "Dune??" }); + const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } }); + await moveEntryToWork(db, userId, draft.id, shared, "user"); + const [move] = await getHistory(db, userId, "entry", draft.id); + await restoreVersion(db, userId, move!.id); + const back = await getEntry(db, userId, draft.id); + expect(back.workId).toBe(draft.workId); + expect(back.draft).toBe(true); + const work = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow(); + expect(work.merged_into_id).toBeNull(); + // the shared work now aliases back to the draft for this user; no alias cycle remains + const aliases = await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute(); + expect(aliases.map((a) => [a.from_work_id, a.to_work_id])).toEqual([[shared, draft.workId]]); + }); + + test("another user's history is invisible", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const e = await upsertEntry(db, ada, await makeWork(db), { review: "x" }, "user"); + expect(await getHistory(db, bob, "entry", e.id)).toEqual([]); + const [h] = await getHistory(db, ada, "entry", e.id); + await expect(restoreVersion(db, bob, h!.id)).rejects.toThrow(NotFoundError); + }); +}); diff --git a/packages/core/test/suggestions.test.ts b/packages/core/test/suggestions.test.ts new file mode 100644 index 0000000..991020c --- /dev/null +++ b/packages/core/test/suggestions.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { forEachDialect } from "@sttracker/db/testing"; +import { acceptSuggestion, getEntry, quickAdd, rejectSuggestion } from "../src/entries.ts"; +import { NotFoundError } from "../src/errors.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +async function suggest(db: Db, userId: string, workId: string, candidateWorkId: string): Promise { + const id = newId(); + await db.k + .insertInto("match_suggestions") + .values({ id, user_id: userId, work_id: workId, candidate_work_id: candidateWorkId, score: 0.9, status: "pending", created_at: nowIso() }) + .execute(); + return id; +} + +async function statusOf(db: Db, id: string): Promise { + return (await db.k.selectFrom("match_suggestions").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status; +} + +forEachDialect("suggestions", (getDb) => { + test("acceptSuggestion moves the entry and rejects the other pending suggestions", async () => { + const db = getDb(); + const userId = await makeUser(db); + const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" }); + const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } }); + const other = await makeWork(db, { title: "Dune Messiah" }); + const s1 = await suggest(db, userId, draft.workId, good); + const s2 = await suggest(db, userId, draft.workId, other); + await acceptSuggestion(db, userId, s1); + expect(await statusOf(db, s1)).toBe("accepted"); + expect(await statusOf(db, s2)).toBe("rejected"); + const view = await getEntry(db, userId, draft.id); + expect(view.workId).toBe(good); + expect(view.draft).toBe(false); + }); + + test("rejectSuggestion; other users get NotFoundError", async () => { + const db = getDb(); + const ada = await makeUser(db); + const bob = await makeUser(db, "bob"); + const s = await suggest(db, ada, await makeWork(db), await makeWork(db)); + await expect(acceptSuggestion(db, bob, s)).rejects.toThrow(NotFoundError); + await expect(rejectSuggestion(db, bob, s)).rejects.toThrow(NotFoundError); + expect(await statusOf(db, s)).toBe("pending"); + await rejectSuggestion(db, ada, s); + expect(await statusOf(db, s)).toBe("rejected"); + }); +});