feat(core): custom field definitions and value validation

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:56:51 +01:00
co-authored by Claude Opus 5.5
parent d392a65aa7
commit 56b86e27da
3 changed files with 344 additions and 0 deletions
+176
View File
@@ -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;
}
}