fieldOwner(actor) maps every non-plugin actor to "user" wherever field_sources is written (entries, engagements, list ownership from history), so syncs never overwrite a field an API token edited. authenticateAppToken also returns the token name; revokeAppToken reports whether it revoked; listSuggestions queries the canonical work id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
9.3 KiB
TypeScript
277 lines
9.3 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 } from "@sttracker/db/schema";
|
|
import type { PartialDate } from "@sttracker/plugin-sdk/types";
|
|
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
|
|
import {
|
|
checkKeys,
|
|
checkRating,
|
|
checkText,
|
|
type FieldSources,
|
|
fromStoredDate,
|
|
requireEntry,
|
|
toStoredDate,
|
|
} from "./entries.ts";
|
|
import { NotFoundError, ValidationError } from "./errors.ts";
|
|
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts";
|
|
|
|
/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */
|
|
export interface EngagementPatch {
|
|
startedAt: PartialDate | null;
|
|
finishedAt: PartialDate | null;
|
|
progress: Record<string, number | string>;
|
|
platform: string | null;
|
|
rating: number | null;
|
|
notes: string | null;
|
|
customFields: Record<string, unknown>;
|
|
}
|
|
export const ENGAGEMENT_PATCH_KEYS = [
|
|
"startedAt",
|
|
"finishedAt",
|
|
"progress",
|
|
"platform",
|
|
"rating",
|
|
"notes",
|
|
"customFields",
|
|
] as const satisfies readonly (keyof EngagementPatch)[];
|
|
|
|
export interface Engagement extends EngagementPatch {
|
|
id: string;
|
|
entryId: string;
|
|
fieldSources: FieldSources;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
deletedAt: string | null;
|
|
}
|
|
|
|
type Row = Database["engagements"];
|
|
|
|
function toEngagement(r: Row): Engagement {
|
|
return {
|
|
id: r.id,
|
|
entryId: r.entry_id,
|
|
startedAt: fromStoredDate(r.started_at, r.started_precision),
|
|
finishedAt: fromStoredDate(r.finished_at, r.finished_precision),
|
|
progress: fromJson<Record<string, number | string>>(r.progress, {}),
|
|
platform: r.platform,
|
|
rating: r.rating,
|
|
notes: r.notes,
|
|
customFields: fromJson<Record<string, unknown>>(r.custom_fields, {}),
|
|
fieldSources: fromJson<FieldSources>(r.field_sources, {}),
|
|
createdAt: r.created_at,
|
|
updatedAt: r.updated_at,
|
|
deletedAt: r.deleted_at,
|
|
};
|
|
}
|
|
|
|
function snapshot(e: Engagement): Record<string, unknown> {
|
|
const s: Record<string, unknown> = { entryId: e.entryId, deletedAt: e.deletedAt };
|
|
for (const k of ENGAGEMENT_PATCH_KEYS) s[k] = e[k];
|
|
return s;
|
|
}
|
|
|
|
/** Validate a patch and turn it into columns. */
|
|
async function toColumns(
|
|
k: Executor,
|
|
userId: string,
|
|
mediaType: string,
|
|
patch: Partial<EngagementPatch>,
|
|
): Promise<Partial<Row>> {
|
|
checkKeys("patch", patch, ENGAGEMENT_PATCH_KEYS);
|
|
const c: Partial<Row> = {};
|
|
if ("startedAt" in patch) {
|
|
const d = toStoredDate("startedAt", patch.startedAt);
|
|
c.started_at = d.value;
|
|
c.started_precision = d.precision;
|
|
}
|
|
if ("finishedAt" in patch) {
|
|
const d = toStoredDate("finishedAt", patch.finishedAt);
|
|
c.finished_at = d.value;
|
|
c.finished_precision = d.precision;
|
|
}
|
|
if ("progress" in patch) {
|
|
const p = patch.progress;
|
|
if (p === null || typeof p !== "object" || Array.isArray(p)) throw new ValidationError("progress", "not_object");
|
|
for (const [key, v] of Object.entries(p)) {
|
|
if (typeof v !== "string" && (typeof v !== "number" || !Number.isFinite(v))) {
|
|
throw new ValidationError(`progress.${key}`, "invalid");
|
|
}
|
|
}
|
|
c.progress = toJson(p);
|
|
}
|
|
if ("platform" in patch) c.platform = checkText("platform", patch.platform);
|
|
if ("rating" in patch) c.rating = checkRating("rating", patch.rating);
|
|
if ("notes" in patch) c.notes = checkText("notes", patch.notes);
|
|
if ("customFields" in patch) {
|
|
const defs = await customFieldsFor(k, userId, { mediaType, level: "engagement" });
|
|
validateCustomValues(defs, patch.customFields as Record<string, unknown>);
|
|
c.custom_fields = toJson(patch.customFields);
|
|
}
|
|
return c;
|
|
}
|
|
|
|
/** Load an engagement whose entry belongs to the user. */
|
|
async function load(
|
|
k: Executor,
|
|
userId: string,
|
|
engagementId: string,
|
|
opts: { includeDeleted?: boolean } = {},
|
|
): Promise<{ eg: Engagement; mediaType: string }> {
|
|
let q = k
|
|
.selectFrom("engagements as g")
|
|
.innerJoin("entries as e", "e.id", "g.entry_id")
|
|
.innerJoin("works as w", "w.id", "e.work_id")
|
|
.selectAll("g")
|
|
.select(["e.media_type_override", "w.media_type"])
|
|
.where("g.id", "=", engagementId)
|
|
.where("e.user_id", "=", userId);
|
|
if (!opts.includeDeleted) q = q.where("g.deleted_at", "is", null).where("e.deleted_at", "is", null);
|
|
const r = await q.executeTakeFirst();
|
|
if (!r) throw new NotFoundError("engagement");
|
|
return { eg: toEngagement(r), mediaType: r.media_type_override ?? r.media_type };
|
|
}
|
|
|
|
async function write(
|
|
k: Executor,
|
|
userId: string,
|
|
current: Engagement,
|
|
mediaType: string,
|
|
patch: Partial<EngagementPatch>,
|
|
actor: Actor,
|
|
extra: Partial<Pick<Row, "deleted_at" | "entry_id">> = {},
|
|
): Promise<Engagement> {
|
|
const cols = await toColumns(k, userId, mediaType, patch);
|
|
const fieldSources = { ...current.fieldSources };
|
|
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
|
|
await k
|
|
.updateTable("engagements")
|
|
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
|
|
.where("id", "=", current.id)
|
|
.execute();
|
|
const { eg } = await load(k, userId, current.id, { includeDeleted: true });
|
|
await recordHistory(k, {
|
|
userId,
|
|
entity: "engagement",
|
|
entityId: current.id,
|
|
before: snapshot(current),
|
|
after: snapshot(eg),
|
|
actor,
|
|
});
|
|
return eg;
|
|
}
|
|
|
|
/** Add an engagement to the user's live entry. Each patched key gets `field_sources[key] = actor`. */
|
|
export async function addEngagement(
|
|
db: Db,
|
|
userId: string,
|
|
entryId: string,
|
|
patch: Partial<EngagementPatch>,
|
|
actor: Actor,
|
|
tx?: Executor,
|
|
): Promise<Engagement> {
|
|
return inTx(db, tx, async (k) => {
|
|
const { mediaType } = await requireEntry(k, userId, entryId);
|
|
const cols = await toColumns(k, userId, mediaType, patch);
|
|
const fieldSources: FieldSources = {};
|
|
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
|
|
const now = nowIso();
|
|
const row: Row = {
|
|
id: newId(),
|
|
entry_id: entryId,
|
|
started_at: null,
|
|
started_precision: "unknown",
|
|
finished_at: null,
|
|
finished_precision: "unknown",
|
|
progress: toJson({}),
|
|
platform: null,
|
|
rating: null,
|
|
notes: null,
|
|
custom_fields: toJson({}),
|
|
...cols,
|
|
field_sources: toJson(fieldSources),
|
|
created_at: now,
|
|
updated_at: now,
|
|
deleted_at: null,
|
|
};
|
|
await k.insertInto("engagements").values(row).execute();
|
|
const eg = toEngagement(row);
|
|
await recordHistory(k, { userId, entity: "engagement", entityId: eg.id, before: null, after: snapshot(eg), actor });
|
|
return eg;
|
|
});
|
|
}
|
|
|
|
/** Patch a live engagement; only the keys present are touched. Writes history. */
|
|
export async function updateEngagement(
|
|
db: Db,
|
|
userId: string,
|
|
engagementId: string,
|
|
patch: Partial<EngagementPatch>,
|
|
actor: Actor,
|
|
tx?: Executor,
|
|
): Promise<Engagement> {
|
|
return inTx(db, tx, async (k) => {
|
|
const { eg, mediaType } = await load(k, userId, engagementId);
|
|
if (Object.keys(patch).length === 0) return eg;
|
|
return write(k, userId, eg, mediaType, patch, actor);
|
|
});
|
|
}
|
|
|
|
/** Soft-delete an engagement. Writes history. */
|
|
export async function softDeleteEngagement(
|
|
db: Db,
|
|
userId: string,
|
|
engagementId: string,
|
|
actor: Actor,
|
|
tx?: Executor,
|
|
): Promise<void> {
|
|
await inTx(db, tx, async (k) => {
|
|
const { eg, mediaType } = await load(k, userId, engagementId);
|
|
await write(k, userId, eg, mediaType, {}, actor, { deleted_at: nowIso() });
|
|
});
|
|
}
|
|
|
|
/** The live engagements of the user's live entry, by start date (undated last), then creation. */
|
|
export async function listEngagements(db: Db, userId: string, entryId: string): Promise<Engagement[]> {
|
|
await requireEntry(db.k, userId, entryId);
|
|
const rows = await db.k
|
|
.selectFrom("engagements")
|
|
.selectAll()
|
|
.where("entry_id", "=", entryId)
|
|
.where("deleted_at", "is", null)
|
|
.orderBy(sql`case when started_at is null then 1 else 0 end`)
|
|
.orderBy("started_at")
|
|
.orderBy("created_at")
|
|
.orderBy("id")
|
|
.execute();
|
|
return rows.map(toEngagement);
|
|
}
|
|
|
|
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
|
export async function restoreEngagementFromHistory(
|
|
k: Executor,
|
|
userId: string,
|
|
engagementId: string,
|
|
before: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const { eg, mediaType } = await load(k, userId, engagementId, { includeDeleted: true });
|
|
const patch: Record<string, unknown> = {};
|
|
for (const key of ENGAGEMENT_PATCH_KEYS) if (key in before) patch[key] = before[key];
|
|
const extra: Partial<Pick<Row, "deleted_at" | "entry_id">> =
|
|
"deletedAt" in before ? { deleted_at: (before.deletedAt as string | null) ?? null } : {};
|
|
if (typeof before.entryId === "string" && before.entryId !== eg.entryId) {
|
|
// Undo a merge's re-parenting; the old entry must be this user's.
|
|
const owner = await k
|
|
.selectFrom("entries")
|
|
.select(["id", "deleted_at"])
|
|
.where("id", "=", before.entryId)
|
|
.where("user_id", "=", userId)
|
|
.executeTakeFirst();
|
|
if (!owner) throw new NotFoundError("entry");
|
|
if (owner.deleted_at !== null) throw new ValidationError("entryId", "target entry is deleted");
|
|
extra.entry_id = before.entryId;
|
|
}
|
|
await write(k, userId, eg, mediaType, patch as Partial<EngagementPatch>, "user", extra);
|
|
}
|