feat(core): custom field definitions and value validation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d392a65aa7
commit
56b86e27da
@@ -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<string[]>(r.options, []),
|
||||
appliesTo: fromJson<string[] | "all">(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<CustomFieldDef> {
|
||||
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<CustomFieldDef[]> {
|
||||
return customFieldsFor(db.k, userId, { mediaType, level });
|
||||
}
|
||||
|
||||
/** Same as `listCustomFields`, usable with a transaction handle. */
|
||||
export async function customFieldsFor(
|
||||
k: Kysely<Database>,
|
||||
userId: string,
|
||||
opts: { mediaType?: string | null; level?: CustomFieldLevel },
|
||||
): Promise<CustomFieldDef[]> {
|
||||
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.<name or key>", reason)`.
|
||||
*/
|
||||
export function validateCustomValues(defs: CustomFieldDef[], values: Record<string, unknown>): 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;
|
||||
}
|
||||
}
|
||||
@@ -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, unknown>, 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);
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown> = {}): Promise<string> {
|
||||
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<string, string>;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user