- lists read/write real lists.* tables via meta.tables/meta.columns and the SECURITY DEFINER meta functions; typed extra columns in the item forms - old numeric list URLs 308-redirect to the slug - include_recurring is boolean, osu accuracy/pp/rate numeric - raw SQL qualified with the new schemas
297 lines
8.7 KiB
TypeScript
297 lines
8.7 KiB
TypeScript
// Generic list engine. Every list is a real table in schema `lists`, registered
|
|
// in meta.tables / meta.columns. Table and column names only ever come from the
|
|
// registry (written by the SECURITY DEFINER meta.* functions, which validate
|
|
// identifiers) and are re-checked here before being used as identifiers.
|
|
import { and, asc, desc, eq, isNull, max, notInArray, sql } from 'drizzle-orm';
|
|
import { db } from '#lib/server/db/index.js';
|
|
import {
|
|
metaColumns,
|
|
metaTables,
|
|
type MetaTable,
|
|
type UiType
|
|
} from '#lib/server/db/schema/index.js';
|
|
|
|
const IDENT = /^[a-z][a-z0-9_]{0,62}$/;
|
|
/** Columns every list has; everything else is a list-specific extra column. */
|
|
const STANDARD = [
|
|
'id',
|
|
'name',
|
|
'notes',
|
|
'picture_url',
|
|
'done_at',
|
|
'sort_order',
|
|
'created_at',
|
|
'updated_at'
|
|
];
|
|
|
|
export type ExtraValue = string | number | boolean | null;
|
|
export type ListColumn = { name: string; label: string; uiType: UiType; options: string[] };
|
|
export type ListItem = {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
pictureUrl: string | null;
|
|
doneAt: Date | null;
|
|
extras: Record<string, ExtraValue>;
|
|
};
|
|
export type ListSummary = {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
pictureUrl: string | null;
|
|
isProgress: boolean;
|
|
total: number;
|
|
checked: number;
|
|
};
|
|
export type ListView = Omit<ListSummary, 'total' | 'checked'> & {
|
|
columns: ListColumn[];
|
|
items: ListItem[];
|
|
};
|
|
|
|
function table(t: Pick<MetaTable, 'schemaName' | 'tableName'>) {
|
|
if (t.schemaName !== 'lists' || !IDENT.test(t.tableName))
|
|
throw new Error(`refusing unexpected list table ${t.schemaName}.${t.tableName}`);
|
|
return sql`${sql.identifier('lists')}.${sql.identifier(t.tableName)}`;
|
|
}
|
|
|
|
const activeList = and(eq(metaTables.kind, 'list'), isNull(metaTables.archivedAt));
|
|
|
|
export async function findList(slug: string) {
|
|
const [row] = await db
|
|
.select()
|
|
.from(metaTables)
|
|
.where(and(activeList, eq(metaTables.slug, slug)));
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function listSummaries(): Promise<ListSummary[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(metaTables)
|
|
.where(activeList)
|
|
.orderBy(desc(metaTables.sortOrder), desc(metaTables.id));
|
|
if (!rows.length) return [];
|
|
const counts = await db.execute<{ id: number; total: number; checked: number }>(
|
|
sql.join(
|
|
rows.map(
|
|
(t) =>
|
|
sql`SELECT ${t.id}::int AS id, count(*)::int AS total, ${
|
|
t.hasDone ? sql`count(done_at)::int` : sql`0`
|
|
} AS checked FROM ${table(t)}`
|
|
),
|
|
sql` UNION ALL `
|
|
)
|
|
);
|
|
const byId = new Map([...counts].map((c) => [Number(c.id), c]));
|
|
return rows.map((t) => ({
|
|
id: t.id,
|
|
name: t.displayName,
|
|
slug: t.slug,
|
|
pictureUrl: t.pictureUrl,
|
|
isProgress: t.hasDone,
|
|
total: byId.get(t.id)?.total ?? 0,
|
|
checked: byId.get(t.id)?.checked ?? 0
|
|
}));
|
|
}
|
|
|
|
export async function listColumns(t: MetaTable): Promise<ListColumn[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(metaColumns)
|
|
.where(
|
|
and(
|
|
eq(metaColumns.tableId, t.id),
|
|
eq(metaColumns.hidden, false),
|
|
notInArray(metaColumns.columnName, STANDARD)
|
|
)
|
|
)
|
|
.orderBy(asc(metaColumns.position));
|
|
return rows
|
|
.filter((c) => IDENT.test(c.columnName))
|
|
.map((c) => ({
|
|
name: c.columnName,
|
|
label: c.label ?? c.columnName.replace(/_/g, ' '),
|
|
uiType: c.uiType,
|
|
options: c.options.values ?? []
|
|
}));
|
|
}
|
|
|
|
type Row = Record<string, unknown> & { id: number; name: string };
|
|
function toItem(row: Row, columns: ListColumn[]): ListItem {
|
|
const extras: Record<string, ExtraValue> = {};
|
|
for (const c of columns) {
|
|
const v = row[c.name];
|
|
extras[c.name] = v instanceof Date ? v.toISOString() : ((v ?? null) as ExtraValue);
|
|
}
|
|
return {
|
|
id: Number(row.id),
|
|
name: row.name,
|
|
description: (row.notes as string | null) ?? null,
|
|
pictureUrl: (row.picture_url as string | null) ?? null,
|
|
doneAt: row.done_at ? new Date(row.done_at as string) : null,
|
|
extras
|
|
};
|
|
}
|
|
|
|
export async function getList(slug: string): Promise<ListView | null> {
|
|
const t = await findList(slug);
|
|
if (!t) return null;
|
|
const columns = await listColumns(t);
|
|
const rows = await db.execute<Row>(sql`SELECT * FROM ${table(t)} ORDER BY sort_order, id`);
|
|
return {
|
|
id: t.id,
|
|
name: t.displayName,
|
|
slug: t.slug,
|
|
pictureUrl: t.pictureUrl,
|
|
isProgress: t.hasDone,
|
|
columns,
|
|
items: [...rows].map((r) => toItem(r, columns))
|
|
};
|
|
}
|
|
|
|
/** Parse the `extra:<column>` form fields for this list's columns. */
|
|
export function extraFields(
|
|
form: FormData,
|
|
columns: ListColumn[]
|
|
): { values: Record<string, ExtraValue> } | { error: string } {
|
|
const values: Record<string, ExtraValue> = {};
|
|
for (const c of columns) {
|
|
const key = `extra:${c.name}`;
|
|
if (c.uiType === 'bool') {
|
|
values[c.name] = form.get(key) === 'on';
|
|
continue;
|
|
}
|
|
if (!form.has(key)) continue;
|
|
const raw = String(form.get(key) ?? '').trim();
|
|
if (!raw) {
|
|
values[c.name] = null;
|
|
continue;
|
|
}
|
|
if (raw.length > 2000) return { error: `${c.label} is too long.` };
|
|
switch (c.uiType) {
|
|
case 'number':
|
|
case 'cents': {
|
|
const n = Number(raw);
|
|
if (!Number.isFinite(n) || (c.uiType === 'cents' && !Number.isInteger(n)))
|
|
return { error: `${c.label} must be a number.` };
|
|
values[c.name] = n;
|
|
break;
|
|
}
|
|
case 'select':
|
|
if (c.options.length && !c.options.includes(raw))
|
|
return { error: `${c.label} must be one of: ${c.options.join(', ')}.` };
|
|
values[c.name] = raw;
|
|
break;
|
|
case 'url':
|
|
case 'image':
|
|
if (!/^https?:\/\/\S+$/.test(raw)) return { error: `${c.label} must be an http(s) URL.` };
|
|
values[c.name] = raw;
|
|
break;
|
|
case 'date':
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return { error: `${c.label} must be a date.` };
|
|
values[c.name] = raw;
|
|
break;
|
|
case 'text':
|
|
case 'longtext':
|
|
values[c.name] = raw;
|
|
break;
|
|
default:
|
|
// json / multiselect / fk are not editable from this form yet
|
|
break;
|
|
}
|
|
}
|
|
return { values };
|
|
}
|
|
|
|
function columnsAndValues(values: Record<string, ExtraValue>) {
|
|
const entries = Object.entries(values).filter(([k]) => IDENT.test(k));
|
|
return {
|
|
cols: entries.map(([k]) => sql.identifier(k)),
|
|
vals: entries.map(([, v]) => sql`${v}`),
|
|
sets: entries.map(([k, v]) => sql`${sql.identifier(k)} = ${v}`)
|
|
};
|
|
}
|
|
|
|
export type ItemValues = {
|
|
name: string;
|
|
description: string | null;
|
|
pictureUrl?: string | null;
|
|
extras: Record<string, ExtraValue>;
|
|
};
|
|
function rowValues(v: ItemValues): Record<string, ExtraValue> {
|
|
return {
|
|
...v.extras,
|
|
name: v.name,
|
|
notes: v.description,
|
|
...('pictureUrl' in v ? { picture_url: v.pictureUrl ?? null } : {})
|
|
};
|
|
}
|
|
|
|
export async function insertItem(t: MetaTable, v: ItemValues) {
|
|
const { cols, vals } = columnsAndValues(rowValues(v));
|
|
const tbl = table(t);
|
|
await db.execute(
|
|
sql`INSERT INTO ${tbl} (${sql.join(cols, sql`, `)}, sort_order)
|
|
VALUES (${sql.join(vals, sql`, `)}, (SELECT coalesce(max(sort_order), 0) + 1 FROM ${tbl}))`
|
|
);
|
|
}
|
|
|
|
export async function getItem(t: MetaTable, id: number) {
|
|
const [row] = await db.execute<Row>(sql`SELECT * FROM ${table(t)} WHERE id = ${id}`);
|
|
return row ? toItem(row, []) : null;
|
|
}
|
|
|
|
export async function updateItem(t: MetaTable, id: number, v: ItemValues) {
|
|
const { sets } = columnsAndValues(rowValues(v));
|
|
const rows = await db.execute<{ id: number }>(
|
|
sql`UPDATE ${table(t)} SET ${sql.join(sets, sql`, `)} WHERE id = ${id} RETURNING id`
|
|
);
|
|
return rows.length > 0;
|
|
}
|
|
|
|
export async function deleteItem(t: MetaTable, id: number) {
|
|
const [row] = await db.execute<{ picture_url: string | null }>(
|
|
sql`DELETE FROM ${table(t)} WHERE id = ${id} RETURNING picture_url`
|
|
);
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function setDone(t: MetaTable, id: number, done: boolean) {
|
|
if (!t.hasDone) throw new Error('not a progress list');
|
|
const rows = await db.execute<{ id: number }>(
|
|
sql`UPDATE ${table(t)} SET done_at = ${done ? sql`coalesce(done_at, now())` : sql`NULL`}
|
|
WHERE id = ${id} RETURNING id`
|
|
);
|
|
return rows.length > 0;
|
|
}
|
|
|
|
/** Create a new runtime list (a real table) through the meta API. */
|
|
export async function createList(input: {
|
|
slug: string;
|
|
name: string;
|
|
isProgress: boolean;
|
|
pictureUrl: string | null;
|
|
}) {
|
|
let base = input.slug.replace(/-/g, '_').replace(/^[^a-z]+/, '');
|
|
base = (base || 'list').slice(0, 55);
|
|
let tableName = base;
|
|
for (let n = 2; ; n++) {
|
|
const [taken] = await db
|
|
.select({ id: metaTables.id })
|
|
.from(metaTables)
|
|
.where(and(eq(metaTables.schemaName, 'lists'), eq(metaTables.tableName, tableName)));
|
|
if (!taken) break;
|
|
tableName = `${base}_${n}`;
|
|
}
|
|
const [{ top }] = await db.select({ top: max(metaTables.sortOrder) }).from(metaTables);
|
|
await db.execute(
|
|
sql`SELECT meta.create_list(${input.slug}, ${tableName}, ${input.name}, ${input.isProgress},
|
|
'[]'::jsonb, ${input.pictureUrl}, NULL, NULL, ${(top ?? 0) + 1})`
|
|
);
|
|
}
|
|
|
|
/** Soft delete: the table stays for 7 days (meta.drop_list refuses earlier). */
|
|
export async function archiveList(slug: string) {
|
|
await db.execute(sql`SELECT meta.archive_list(${slug})`);
|
|
}
|