feat(core): works catalogue and matching
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d392a65aa7
commit
a46fb60eee
4 files changed
+674
No files matched your search
@@ -0,0 +1,294 @@
|
||||
import { sql, type Kysely } from "kysely";
|
||||
import type { MediaType, WorkHint } from "@sttracker/plugin-sdk/types";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import type { Database, WorksTable } from "@sttracker/db/schema";
|
||||
import { AuthError, NotFoundError, ValidationError } from "./errors.ts";
|
||||
|
||||
export type Creator = { name: string; role?: string };
|
||||
export type Position = { season?: number; episode?: number; index?: number };
|
||||
|
||||
/** A `works` row with JSON columns parsed. Other fields keep their column names. */
|
||||
export interface Work extends Omit<WorksTable, "creators" | "metadata" | "position"> {
|
||||
creators: Creator[];
|
||||
metadata: Record<string, unknown>;
|
||||
position: Position | null;
|
||||
}
|
||||
|
||||
/** Any Kysely handle: the root one or a transaction. */
|
||||
export type K = Kysely<Database>;
|
||||
|
||||
/** Normalised JSON for `works.position`: keys in a fixed order (season, episode, index), or null when empty. */
|
||||
export function normalizePosition(p: Position | undefined | null): string | null {
|
||||
if (!p) return null;
|
||||
const o: Position = {};
|
||||
if (p.season !== undefined) o.season = p.season;
|
||||
if (p.episode !== undefined) o.episode = p.episode;
|
||||
if (p.index !== undefined) o.index = p.index;
|
||||
return Object.keys(o).length === 0 ? null : toJson(o);
|
||||
}
|
||||
|
||||
export function toWork(r: WorksTable): Work {
|
||||
return {
|
||||
...r,
|
||||
creators: fromJson<Creator[]>(r.creators, []),
|
||||
metadata: fromJson<Record<string, unknown>>(r.metadata, {}),
|
||||
position: fromJson<Position | null>(r.position, null),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWorkIn(
|
||||
k: K,
|
||||
input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null },
|
||||
actor: string,
|
||||
): Promise<string> {
|
||||
const { hint } = input;
|
||||
if (hint.title.trim() === "") throw new ValidationError("title", "required");
|
||||
const id = newId();
|
||||
const now = nowIso();
|
||||
await k
|
||||
.insertInto("works")
|
||||
.values({
|
||||
id,
|
||||
media_type: hint.mediaType,
|
||||
title: hint.title,
|
||||
original_title: "",
|
||||
year: hint.year ?? null,
|
||||
release_date: null,
|
||||
creators: toJson(hint.creators ?? []),
|
||||
description: hint.description ?? "",
|
||||
cover_url: hint.coverUrl ?? "",
|
||||
cover_attachment_id: null,
|
||||
metadata: "{}",
|
||||
parent_id: input.parentId ?? null,
|
||||
position: normalizePosition(hint.position),
|
||||
owner_user_id: input.ownerUserId ?? null,
|
||||
merged_into_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.execute();
|
||||
for (const [namespace, value] of Object.entries(hint.externalIds)) {
|
||||
await k.insertInto("external_ids").values({ id: newId(), work_id: id, namespace, value }).execute();
|
||||
}
|
||||
await k
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: id, before: "{}", after: toJson({ created: true, title: hint.title }), actor, created_at: now })
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Insert the work and its external IDs. */
|
||||
export function createWork(
|
||||
db: Db,
|
||||
input: { hint: WorkHint; ownerUserId?: string | null; parentId?: string | null },
|
||||
actor: string,
|
||||
): Promise<string> {
|
||||
return db.k.transaction().execute((tx) => createWorkIn(tx, input, actor));
|
||||
}
|
||||
|
||||
export async function getWorkIn(k: K, id: string): Promise<Work | null> {
|
||||
const seen = new Set<string>();
|
||||
let cur: string | null = id;
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const row: WorksTable | undefined = await k.selectFrom("works").selectAll().where("id", "=", cur).executeTakeFirst();
|
||||
if (!row) return null;
|
||||
if (!row.merged_into_id) return toWork(row);
|
||||
cur = row.merged_into_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fetch a work, following `merged_into_id` tombstones to the surviving one. */
|
||||
export function getWork(db: Db, id: string): Promise<Work | null> {
|
||||
return getWorkIn(db.k, id);
|
||||
}
|
||||
|
||||
/** Follow `merged_into_id` to the surviving id (the input id if it is not merged). */
|
||||
export async function finalWorkId(k: K, id: string): Promise<string> {
|
||||
return (await getWorkIn(k, id))?.id ?? id;
|
||||
}
|
||||
|
||||
export async function findWorkByExternalIdsIn(k: K, ids: Record<string, string>): Promise<string | null> {
|
||||
for (const [namespace, value] of Object.entries(ids)) {
|
||||
const row = await k
|
||||
.selectFrom("external_ids")
|
||||
.select("work_id")
|
||||
.where("namespace", "=", namespace)
|
||||
.where("value", "=", value)
|
||||
.executeTakeFirst();
|
||||
if (row) return finalWorkId(k, row.work_id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** First work matching any (namespace, value) pair, following merges. */
|
||||
export function findWorkByExternalIds(db: Db, ids: Record<string, string>): Promise<string | null> {
|
||||
return findWorkByExternalIdsIn(db.k, ids);
|
||||
}
|
||||
|
||||
async function addMissingExternalIds(k: K, workId: string, ids: Record<string, string>): Promise<string[]> {
|
||||
const added: string[] = [];
|
||||
for (const [namespace, value] of Object.entries(ids)) {
|
||||
const exists = await k
|
||||
.selectFrom("external_ids")
|
||||
.select("id")
|
||||
.where("namespace", "=", namespace)
|
||||
.where("value", "=", value)
|
||||
.executeTakeFirst();
|
||||
if (exists) continue;
|
||||
await k.insertInto("external_ids").values({ id: newId(), work_id: workId, namespace, value }).execute();
|
||||
added.push(`${namespace}:${value}`);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
export async function updateWorkFromHintIn(k: K, workId: string, hint: WorkHint, actor: string): Promise<void> {
|
||||
const row = await k.selectFrom("works").selectAll().where("id", "=", workId).executeTakeFirst();
|
||||
if (!row || row.owner_user_id !== null) return; // private works are never touched by syncs
|
||||
const before: Record<string, unknown> = {};
|
||||
const after: Record<string, unknown> = {};
|
||||
const patch: Partial<WorksTable> = {};
|
||||
const set = <F extends "title" | "year" | "creators" | "description" | "cover_url">(field: F, next: WorksTable[F] | undefined, empty: boolean) => {
|
||||
if (next === undefined || empty || next === row[field]) return;
|
||||
before[field] = row[field];
|
||||
after[field] = next;
|
||||
patch[field] = next;
|
||||
};
|
||||
set("title", hint.title, hint.title.trim() === "");
|
||||
set("year", hint.year, hint.year === undefined);
|
||||
set("creators", hint.creators ? toJson(hint.creators) : undefined, !hint.creators || hint.creators.length === 0);
|
||||
set("description", hint.description, !hint.description);
|
||||
set("cover_url", hint.coverUrl, !hint.coverUrl);
|
||||
const addedIds = await addMissingExternalIds(k, workId, hint.externalIds);
|
||||
if (addedIds.length > 0) after.externalIds = addedIds;
|
||||
if (Object.keys(after).length === 0) return;
|
||||
const now = nowIso();
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await k.updateTable("works").set({ ...patch, updated_at: now }).where("id", "=", workId).execute();
|
||||
}
|
||||
await k
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: workId, before: toJson(before), after: toJson(after), actor, created_at: now })
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Fill or update a shared work's display fields from a hint, add missing external IDs, log to `work_history`. */
|
||||
export function updateWorkFromHint(db: Db, workId: string, hint: WorkHint, actor: string): Promise<void> {
|
||||
return db.k.transaction().execute((tx) => updateWorkFromHintIn(tx, workId, hint, actor));
|
||||
}
|
||||
|
||||
function escapeLike(s: string): string {
|
||||
return s.replace(/[!%_]/g, (c) => `!${c}`);
|
||||
}
|
||||
|
||||
/** Shared works plus the user's own private ones, case-insensitive substring match on title. */
|
||||
export async function searchWorks(
|
||||
db: Db,
|
||||
userId: string,
|
||||
q: string,
|
||||
opts: { mediaType?: MediaType; limit?: number } = {},
|
||||
): Promise<Work[]> {
|
||||
let qb = db.k
|
||||
.selectFrom("works")
|
||||
.selectAll()
|
||||
.where("merged_into_id", "is", null)
|
||||
.where((eb) => eb.or([eb("owner_user_id", "is", null), eb("owner_user_id", "=", userId)]))
|
||||
.where(sql<boolean>`lower(title) like ${`%${escapeLike(q.toLowerCase())}%`} escape '!'`);
|
||||
if (opts.mediaType) qb = qb.where("media_type", "=", opts.mediaType);
|
||||
const rows = await qb.orderBy("title").limit(opts.limit ?? 50).execute();
|
||||
return rows.map(toWork);
|
||||
}
|
||||
|
||||
/** Follow the user's aliases and global merge tombstones to the work id they should see. */
|
||||
export async function resolveAliasIn(k: K, userId: string, workId: string): Promise<string> {
|
||||
const seen = new Set<string>();
|
||||
let cur = workId;
|
||||
while (!seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const alias = await k
|
||||
.selectFrom("work_aliases")
|
||||
.select("to_work_id")
|
||||
.where("user_id", "=", userId)
|
||||
.where("from_work_id", "=", cur)
|
||||
.executeTakeFirst();
|
||||
if (alias) {
|
||||
cur = alias.to_work_id;
|
||||
continue;
|
||||
}
|
||||
const w = await k.selectFrom("works").select("merged_into_id").where("id", "=", cur).executeTakeFirst();
|
||||
if (!w?.merged_into_id) break;
|
||||
cur = w.merged_into_id;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function resolveAlias(db: Db, userId: string, workId: string): Promise<string> {
|
||||
return resolveAliasIn(db.k, userId, workId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only global merge of shared work `fromId` into `toId`. Leaves a `merged_into_id` tombstone and
|
||||
* moves external IDs. Users without an entry on `toId` get their entry repointed; users who already
|
||||
* have one keep their `from` entry and get a pending `merged` change event.
|
||||
*/
|
||||
export async function adminMergeWorks(db: Db, adminUserId: string, fromId: string, toId: string): Promise<void> {
|
||||
await db.k.transaction().execute(async (tx) => {
|
||||
const admin = await tx.selectFrom("users").select("role").where("id", "=", adminUserId).executeTakeFirst();
|
||||
if (admin?.role !== "admin") throw new AuthError("forbidden");
|
||||
if (fromId === toId) throw new ValidationError("toId", "cannot merge a work into itself");
|
||||
const from = await tx.selectFrom("works").selectAll().where("id", "=", fromId).executeTakeFirst();
|
||||
const to = await tx.selectFrom("works").selectAll().where("id", "=", toId).executeTakeFirst();
|
||||
if (!from) throw new NotFoundError("work");
|
||||
if (!to) throw new NotFoundError("work");
|
||||
if (from.merged_into_id) throw new ValidationError("fromId", "already merged");
|
||||
if (to.merged_into_id) throw new ValidationError("toId", "already merged");
|
||||
const now = nowIso();
|
||||
|
||||
await tx.updateTable("works").set({ merged_into_id: toId, updated_at: now }).where("id", "=", fromId).execute();
|
||||
const moved = await tx.selectFrom("external_ids").select(["namespace", "value"]).where("work_id", "=", fromId).execute();
|
||||
await tx.updateTable("external_ids").set({ work_id: toId }).where("work_id", "=", fromId).execute();
|
||||
|
||||
const entries = await tx.selectFrom("entries").select(["id", "user_id"]).where("work_id", "=", fromId).execute();
|
||||
for (const e of entries) {
|
||||
const existing = await tx
|
||||
.selectFrom("entries")
|
||||
.select("id")
|
||||
.where("user_id", "=", e.user_id)
|
||||
.where("work_id", "=", toId)
|
||||
.executeTakeFirst();
|
||||
if (existing) {
|
||||
await tx
|
||||
.insertInto("change_events")
|
||||
.values({
|
||||
id: newId(),
|
||||
user_id: e.user_id,
|
||||
connection_id: null,
|
||||
run_id: null,
|
||||
record_id: null,
|
||||
type: "merged",
|
||||
status: "pending",
|
||||
target_entity: "entry",
|
||||
target_id: e.id,
|
||||
payload: toJson({ fromWorkId: fromId, toWorkId: toId }),
|
||||
created_at: now,
|
||||
resolved_at: null,
|
||||
})
|
||||
.execute();
|
||||
} else {
|
||||
await tx.updateTable("entries").set({ work_id: toId, updated_at: now }).where("id", "=", e.id).execute();
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: fromId, before: toJson({ merged_into_id: null }), after: toJson({ merged_into_id: toId }), actor: adminUserId, created_at: now })
|
||||
.execute();
|
||||
await tx
|
||||
.insertInto("work_history")
|
||||
.values({ id: newId(), work_id: toId, before: "{}", after: toJson({ mergedFrom: fromId, externalIds: moved }), actor: adminUserId, created_at: now })
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user