From 56b86e27da5aaacd22bb1ebbba0fb8737934b331 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:56:51 +0100 Subject: [PATCH] feat(core): custom field definitions and value validation Co-Authored-By: Claude Opus 5.5 --- packages/core/src/custom-fields.ts | 176 ++++++++++++++++++++++ packages/core/test/custom-fields.test.ts | 103 +++++++++++++ packages/core/test/user-layer-fixtures.ts | 65 ++++++++ 3 files changed, 344 insertions(+) create mode 100644 packages/core/src/custom-fields.ts create mode 100644 packages/core/test/custom-fields.test.ts create mode 100644 packages/core/test/user-layer-fixtures.ts diff --git a/packages/core/src/custom-fields.ts b/packages/core/src/custom-fields.ts new file mode 100644 index 0000000..229dcab --- /dev/null +++ b/packages/core/src/custom-fields.ts @@ -0,0 +1,176 @@ +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 { CustomFieldLevel, Database } from "@sttracker/db/schema"; +import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date"; +import type { MediaType } from "@sttracker/plugin-sdk/types"; +import { ValidationError } from "./errors.ts"; + +export const CUSTOM_FIELD_TYPES = [ + "text", + "long_text", + "number", + "date", + "boolean", + "single_select", + "multi_select", + "url", + "rating", +] as const; +export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number]; + +const LEVELS: readonly CustomFieldLevel[] = ["entry", "engagement", "copy"]; + +/** + * A user-defined field. Values live in the `custom_fields` JSON of the matching row + * (entry, engagement or copy), keyed by definition **id** so renaming a field keeps its values. + */ +export interface CustomFieldDef { + id: string; + userId: string; + name: string; + type: CustomFieldType; + options: string[]; + appliesTo: string[] | "all"; + level: CustomFieldLevel; + createdAt: string; +} + +export interface CustomFieldInput { + name: string; + type: CustomFieldType; + options?: string[]; + appliesTo?: MediaType[] | string[] | "all"; + level?: CustomFieldLevel; +} + +type Row = Database["custom_field_definitions"]; + +function toDef(r: { [K in keyof Row]: Row[K] }): CustomFieldDef { + return { + id: r.id, + userId: r.user_id, + name: r.name, + type: r.type as CustomFieldType, + options: fromJson(r.options, []), + appliesTo: fromJson(r.applies_to, "all"), + level: r.level, + createdAt: r.created_at, + }; +} + +/** Create a custom field definition. Names are unique per user and level (case-insensitive). */ +export async function defineCustomField(db: Db, userId: string, input: CustomFieldInput): Promise { + const name = typeof input.name === "string" ? input.name.trim() : ""; + if (!name) throw new ValidationError("name", "required"); + if (!CUSTOM_FIELD_TYPES.includes(input.type)) throw new ValidationError("type", "invalid"); + const level = input.level ?? "entry"; + if (!LEVELS.includes(level)) throw new ValidationError("level", "invalid"); + const isSelect = input.type === "single_select" || input.type === "multi_select"; + const options = [...new Set((input.options ?? []).map((o) => String(o).trim()).filter(Boolean))]; + if (isSelect && options.length === 0) throw new ValidationError("options", "required"); + const appliesTo = input.appliesTo ?? "all"; + if (appliesTo !== "all" && (!Array.isArray(appliesTo) || appliesTo.length === 0)) { + throw new ValidationError("appliesTo", "invalid"); + } + + const existing = await db.k + .selectFrom("custom_field_definitions") + .select("name") + .where("user_id", "=", userId) + .where("level", "=", level) + .execute(); + if (existing.some((r) => r.name.toLowerCase() === name.toLowerCase())) { + throw new ValidationError("name", "duplicate"); + } + + const row: Row = { + id: newId(), + user_id: userId, + name, + type: input.type, + options: toJson(isSelect ? options : []), + applies_to: toJson(appliesTo), + level, + created_at: nowIso(), + }; + await db.k.insertInto("custom_field_definitions").values(row).execute(); + return toDef(row); +} + +/** A user's definitions, optionally only those applying to `mediaType` (and/or one level). */ +export async function listCustomFields( + db: Db, + userId: string, + mediaType?: string, + level?: CustomFieldLevel, +): Promise { + return customFieldsFor(db.k, userId, { mediaType, level }); +} + +/** Same as `listCustomFields`, usable with a transaction handle. */ +export async function customFieldsFor( + k: Kysely, + userId: string, + opts: { mediaType?: string | null; level?: CustomFieldLevel }, +): Promise { + let q = k.selectFrom("custom_field_definitions").selectAll().where("user_id", "=", userId); + if (opts.level) q = q.where("level", "=", opts.level); + const rows = await q.orderBy("created_at").orderBy("id").execute(); + return rows + .map(toDef) + .filter((d) => opts.mediaType == null || d.appliesTo === "all" || d.appliesTo.includes(opts.mediaType)); +} + +/** + * Check `values` (keyed by definition id) against `defs`. `null` clears a value and is always allowed. + * Throws `ValidationError("customFields.", reason)`. + */ +export function validateCustomValues(defs: CustomFieldDef[], values: Record): void { + if (values === null || typeof values !== "object" || Array.isArray(values)) { + throw new ValidationError("customFields", "not_object"); + } + const byId = new Map(defs.map((d) => [d.id, d])); + for (const [key, v] of Object.entries(values)) { + const def = byId.get(key); + if (!def) throw new ValidationError(`customFields.${key}`, "unknown_field"); + if (v === null) continue; + const reason = checkValue(def, v); + if (reason) throw new ValidationError(`customFields.${def.name}`, reason); + } +} + +function checkValue(def: CustomFieldDef, v: unknown): string | null { + switch (def.type) { + case "text": + case "long_text": + return typeof v === "string" ? null : "not_text"; + case "number": + return typeof v === "number" && Number.isFinite(v) ? null : "not_number"; + case "date": + return typeof v === "string" && parsePartialDate(v) ? null : "not_date"; + case "boolean": + return typeof v === "boolean" ? null : "not_boolean"; + case "single_select": + if (typeof v !== "string") return "not_text"; + return def.options.includes(v) ? null : "unknown_option"; + case "multi_select": + if (!Array.isArray(v)) return "not_list"; + if (!v.every((x) => typeof x === "string" && def.options.includes(x))) return "unknown_option"; + return new Set(v).size === v.length ? null : "duplicate_option"; + case "url": + return typeof v === "string" && isHttpUrl(v) ? null : "not_url"; + case "rating": + return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100 ? null : "out_of_range"; + } +} + +function isHttpUrl(s: string): boolean { + try { + const u = new URL(s); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} diff --git a/packages/core/test/custom-fields.test.ts b/packages/core/test/custom-fields.test.ts new file mode 100644 index 0000000..06b3b9e --- /dev/null +++ b/packages/core/test/custom-fields.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { ValidationError } from "../src/errors.ts"; +import { defineCustomField, listCustomFields, validateCustomValues } from "../src/custom-fields.ts"; +import { makeUser } from "./user-layer-fixtures.ts"; + +forEachDialect("custom fields", (getDb) => { + test("custom select field rejects unknown option", async () => { + const db = getDb(); + const userId = await makeUser(db); + const mood = await defineCustomField(db, userId, { + name: "Mood", + type: "single_select", + options: ["cosy", "grim"], + }); + expect(() => validateCustomValues([mood], { [mood.id]: "cosy" })).not.toThrow(); + let err: unknown; + try { + validateCustomValues([mood], { [mood.id]: "chaotic" }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).field).toBe("customFields.Mood"); + expect((err as ValidationError).reason).toBe("unknown_option"); + }); + + test("listCustomFields filters by media type and stays per user", async () => { + const db = getDb(); + const userId = await makeUser(db); + const other = await makeUser(db, "bob"); + await defineCustomField(db, userId, { name: "Translator", type: "text", appliesTo: ["book", "manga"] }); + await defineCustomField(db, userId, { name: "Recommended by", type: "text" }); + await defineCustomField(db, other, { name: "Bob field", type: "text" }); + + expect((await listCustomFields(db, userId)).map((d) => d.name).sort()).toEqual(["Recommended by", "Translator"]); + expect((await listCustomFields(db, userId, "game")).map((d) => d.name)).toEqual(["Recommended by"]); + expect((await listCustomFields(db, userId, "manga")).map((d) => d.name).sort()).toEqual([ + "Recommended by", + "Translator", + ]); + }); + + test("defineCustomField rejects bad input", async () => { + const db = getDb(); + const userId = await makeUser(db); + await expect(defineCustomField(db, userId, { name: " ", type: "text" })).rejects.toThrow(ValidationError); + await expect(defineCustomField(db, userId, { name: "X", type: "colour" as never })).rejects.toThrow( + ValidationError, + ); + await expect(defineCustomField(db, userId, { name: "S", type: "single_select" })).rejects.toThrow( + ValidationError, + ); + await defineCustomField(db, userId, { name: "Mood", type: "text" }); + await expect(defineCustomField(db, userId, { name: "mood", type: "text" })).rejects.toThrow(ValidationError); + }); +}); + +test("validateCustomValues checks every type", () => { + const def = (id: string, type: string, options: string[] = []) => ({ + id, + userId: "u", + name: id, + type: type as never, + options, + appliesTo: "all" as const, + level: "entry" as const, + createdAt: "", + }); + const defs = [ + def("t", "text"), + def("n", "number"), + def("d", "date"), + def("b", "boolean"), + def("m", "multi_select", ["a", "b"]), + def("u", "url"), + def("r", "rating"), + ]; + expect(() => + validateCustomValues(defs, { t: "x", n: 1.5, d: "2019", b: true, m: ["a"], u: "https://x.org", r: 80 }), + ).not.toThrow(); + expect(() => validateCustomValues(defs, { t: null })).not.toThrow(); + const bad: Array<[Record, string]> = [ + [{ nope: 1 }, "unknown_field"], + [{ t: 3 }, "not_text"], + [{ n: "3" }, "not_number"], + [{ d: "2019-13" }, "not_date"], + [{ b: 1 }, "not_boolean"], + [{ m: ["c"] }, "unknown_option"], + [{ m: "a" }, "not_list"], + [{ u: "ftp:x" }, "not_url"], + [{ r: 101 }, "out_of_range"], + ]; + for (const [values, reason] of bad) { + let err: unknown; + try { + validateCustomValues(defs, values); + } catch (e) { + err = e; + } + expect((err as ValidationError).reason).toBe(reason); + } +}); diff --git a/packages/core/test/user-layer-fixtures.ts b/packages/core/test/user-layer-fixtures.ts new file mode 100644 index 0000000..90c86d0 --- /dev/null +++ b/packages/core/test/user-layer-fixtures.ts @@ -0,0 +1,65 @@ +import type { Db } from "@sttracker/db/db"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { toJson } from "@sttracker/db/json"; + +/** Insert a user and return its id. */ +export async function makeUser(db: Db, username = "ada", preferences: Record = {}): Promise { + const id = newId(); + const now = nowIso(); + await db.k + .insertInto("users") + .values({ + id, + username, + display_name: username, + password_hash: "x", + role: "member", + preferences: toJson(preferences), + created_at: now, + updated_at: now, + }) + .execute(); + return id; +} + +/** Insert a work (shared unless `ownerUserId` is set) and return its id. */ +export async function makeWork( + db: Db, + opts: { + title?: string; + mediaType?: string; + year?: number | null; + parentId?: string | null; + ownerUserId?: string | null; + externalIds?: Record; + } = {}, +): Promise { + const id = newId(); + const now = nowIso(); + await db.k + .insertInto("works") + .values({ + id, + media_type: opts.mediaType ?? "book", + title: opts.title ?? "Dune", + original_title: "", + year: opts.year ?? null, + release_date: null, + creators: toJson([]), + description: "", + cover_url: "", + cover_attachment_id: null, + metadata: toJson({}), + parent_id: opts.parentId ?? null, + position: null, + owner_user_id: opts.ownerUserId ?? null, + merged_into_id: null, + created_at: now, + updated_at: now, + }) + .execute(); + for (const [namespace, value] of Object.entries(opts.externalIds ?? {})) { + await db.k.insertInto("external_ids").values({ id: newId(), work_id: id, namespace, value }).execute(); + } + return id; +}