Files
STTracker/packages/core/src/engagements.ts
T
Lucas WintherandClaude Opus 5.5 aa8e4db070 fix(core): token writes keep their actor in history but are user-owned in field_sources
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>
2026-10-30 21:29:38 +01:00

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);
}