1009 lines
39 KiB
TypeScript
1009 lines
39 KiB
TypeScript
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<string, Actor>;
|
||
|
||
/** 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<Visibility> {
|
||
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<string, unknown>;
|
||
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<string[]>(r.tags, []),
|
||
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
|
||
visibility: r.visibility,
|
||
overrides: fromJson<EntryOverrides>(r.overrides, {}),
|
||
mediaTypeOverride: r.media_type_override,
|
||
fieldSources: fromJson<FieldSources>(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<string, unknown> {
|
||
const s: Record<string, unknown> = { workId: e.workId, deletedAt: e.deletedAt };
|
||
for (const k of ENTRY_PATCH_KEYS) s[k] = e[k];
|
||
return s;
|
||
}
|
||
|
||
function patchToColumns(p: Partial<EntryPatch>): Partial<EntryRow> {
|
||
const c: Partial<EntryRow> = {};
|
||
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<EntryPatch>,
|
||
opts: { trustCustomFields?: boolean } = {},
|
||
): Promise<Partial<EntryPatch>> {
|
||
checkKeys("patch", patch, ENTRY_PATCH_KEYS);
|
||
const out: Partial<EntryPatch> = {};
|
||
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<string, unknown>;
|
||
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<Pick<EntryRow, "work_id" | "deleted_at">>;
|
||
/** Per-key `field_sources` for patched keys instead of `actor`; `undefined` removes the key. */
|
||
sources?: Record<string, Actor | undefined>;
|
||
/** History `before` override: `null` records a creation. */
|
||
historyBefore?: Record<string, unknown> | null;
|
||
/** Extra keys merged into the history row's before/after snapshots. */
|
||
historyExtra?: { before: Record<string, unknown>; after: Record<string, unknown> };
|
||
}
|
||
|
||
/**
|
||
* 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<EntryPatch>,
|
||
actor: Actor,
|
||
opts: WriteOpts = {},
|
||
): Promise<Entry> {
|
||
const fieldSources: Record<string, Actor | undefined> = { ...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<Entry> {
|
||
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<Entry | null> {
|
||
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<EntryPatch>,
|
||
actor: Actor,
|
||
tx?: Executor,
|
||
): Promise<Entry> {
|
||
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<void> {
|
||
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<string, unknown>,
|
||
): Promise<void> {
|
||
const current = await loadEntry(k, userId, entryId, { includeDeleted: true });
|
||
const patch: Record<string, unknown> = {};
|
||
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<EntryPatch>, {
|
||
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<Entry> {
|
||
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<Entry> {
|
||
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<EntryPatch> = {};
|
||
for (const key of ENTRY_PATCH_KEYS) (all as Record<string, unknown>)[key] = src[key];
|
||
const sources: Record<string, Actor | undefined> = {};
|
||
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<string, unknown> = {};
|
||
const sources: Record<string, Actor | undefined> = {};
|
||
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<EntryPatch>, 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<string> {
|
||
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<EntryView> {
|
||
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<boolean>`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<typeof sql>) =>
|
||
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<boolean>`(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<number>`case when exists (select 1 from external_ids x where x.work_id = w.id) then 0 else 1 end`.as(
|
||
"is_draft",
|
||
),
|
||
sql<number | string>`(select count(*) ${DESCENDANTS})`.as("child_total"),
|
||
sql<number | string>`(select count(*) ${DESCENDANTS} and c.status = 'completed')`.as("child_completed"),
|
||
sql<string>`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<EntryView> {
|
||
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<boolean>`not ${HAS_ENTRY_ANCESTOR}` : underParent(parentId));
|
||
if (filter.mediaType) q = q.where(sql<string>`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<boolean>`lower(e.tags) like ${needle} escape '\\'`);
|
||
}
|
||
if (filter.draft !== undefined) {
|
||
const has = sql<boolean>`exists (select 1 from external_ids x where x.work_id = w.id)`;
|
||
q = q.where(filter.draft ? sql<boolean>`not ${has}` : has);
|
||
}
|
||
if (filter.q?.trim()) {
|
||
const p = `%${escapeLike(filter.q.trim().toLowerCase())}%`;
|
||
q = q.where(
|
||
sql<boolean>`(lower(w.title) like ${p} escape '\\' or lower(w.original_title) like ${p} escape '\\')`,
|
||
);
|
||
}
|
||
|
||
const titleKey = sql<string>`lower(w.title)`;
|
||
const ratingNull = sql<number>`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<void> {
|
||
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<void> {
|
||
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<MatchSuggestion[]> {
|
||
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,
|
||
}));
|
||
}
|