diff --git a/lib/api/crud-builder.ts b/lib/api/crud-builder.ts new file mode 100644 index 0000000..f276572 --- /dev/null +++ b/lib/api/crud-builder.ts @@ -0,0 +1,300 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { getTableColumns } from "drizzle-orm" +import { + type AnyPgTable, + type PgColumn, +} from "drizzle-orm/pg-core" +import { + eq, + desc, + asc, + ilike, + sql, + type SQL, + and, + or, +} from "drizzle-orm" +import { requireRole } from "@/lib/auth/guard" + +/** Which columns are text-searchable via ilike */ +export type CrudSearchConfig = { + fields: string[] +} + +/** Which columns support exact-match filtering via ?filter[col]=val */ +export type CrudFilterConfig = { + fields: string[] +} + +/** Auth requirements per operation */ +export type CrudAuthConfig = { + read: "public" | "auth" + write: "user" | "contributor" | "admin" + delete: "admin" | "contributor" +} + +/** + * Create typed CRUD routes for a Drizzle table. + * + * @param table - Drizzle pgTable definition + * @param config.prefix - URL prefix (e.g., "/games") + * @param config.auth - Auth requirements per operation + * @param config.search - Text search configuration + * @param config.filter - Exact-match filter configuration + * @param config.name - Human-readable name for error messages + * @param config.primaryKey - Column name used as primary key (default: "id") + * @param config.softDelete - If true, DELETE sets isRemoved=true instead of deleting + */ +export function createCrudRoutes( + table: T, + config: { + prefix: string + auth: CrudAuthConfig + search?: CrudSearchConfig + filter?: CrudFilterConfig + name?: string + primaryKey?: string + softDelete?: boolean + }, +) { + const { + prefix, + auth: authConfig, + search, + filter, + name = "resource", + primaryKey = "id", + softDelete = false, + } = config + + const columns = getTableColumns(table) as Record + const pkColumn = columns[primaryKey] + + if (!pkColumn) { + throw new Error(`Primary key column "${primaryKey}" not found on table`) + } + + const routes = new Elysia({ prefix }) + + // ── LIST ────────────────────────────────────────────────────────── + routes.get( + "/", + async ({ query }) => { + const limit = Math.min(Number(query.limit) || 20, 100) + const offset = Number(query.offset) || 0 + const sortCol = columns[query.sort as string] || pkColumn + const order = query.order === "asc" ? asc : desc + + const conditions: SQL[] = [] + + // Search + if (query.search && search) { + const searchConditions = search.fields + .map((field) => { + const col = columns[field] + return col ? ilike(col, `%${query.search}%`) : null + }) + .filter(Boolean) as SQL[] + if (searchConditions.length > 0) { + conditions.push(or(...searchConditions)!) + } + } + + // Filters + if (filter) { + for (const field of filter.fields) { + const val = (query as any)[`filter_${field}`] + if (val !== undefined) { + const col = columns[field] + if (col) { + conditions.push(eq(col, val)) + } + } + } + } + + const where = conditions.length > 0 ? and(...conditions) : undefined + + const [data, countResult] = await Promise.all([ + db + .select() + .from(table as any) + .where(where) + .orderBy(order(sortCol)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)::int` }) + .from(table as any) + .where(where), + ]) + + return { + data, + total: countResult[0]?.count ?? 0, + limit, + offset, + } + }, + { + query: t.Object({ + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + sort: t.Optional(t.String()), + order: t.Optional(t.String()), + search: t.Optional(t.String()), + // Dynamic filter fields are too varied for static TypeBox, + // so we allow any string keys with filter_ prefix + }), + }, + ) + + // ── GET BY ID ───────────────────────────────────────────────────── + routes.get( + `/:${primaryKey}`, + async ({ params, set }) => { + const id = (params as any)[primaryKey] + + const [record] = await db + .select() + .from(table as any) + .where(eq(pkColumn, id)) + .limit(1) + + if (!record) { + set.status = 404 + return { error: `${name} not found` } + } + + return record + }, + { + params: t.Object({ + [primaryKey]: t.String(), + }), + }, + ) + + // ── CREATE ──────────────────────────────────────────────────────── + routes.post( + "/", + async ({ body, request, set }) => { + // Auth check + const roleMap: Record = { + user: ["user", "contributor", "admin"], + contributor: ["contributor", "admin"], + admin: ["admin"], + } + const allowedRoles = roleMap[authConfig.write] + const guard = await requireRole(request.headers, allowedRoles) + + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [created] = (await db.insert(table as any).values(body as any).returning()) as any[] + + set.status = 201 + return created + }, + { + body: t.Record(t.String(), t.Any()), + }, + ) + + // ── UPDATE ──────────────────────────────────────────────────────── + routes.patch( + `/:${primaryKey}`, + async ({ params, body, request, set }) => { + const roleMap: Record = { + user: ["user", "contributor", "admin"], + contributor: ["contributor", "admin"], + admin: ["admin"], + } + const allowedRoles = roleMap[authConfig.write] + const guard = await requireRole(request.headers, allowedRoles) + + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const id = (params as any)[primaryKey] + + // Add updatedAt if column exists + const updateData = columns["updatedAt"] + ? { ...body, updatedAt: new Date() } + : body + + const [updated] = (await db + .update(table as any) + .set(updateData as any) + .where(eq(pkColumn, id)) + .returning()) as any[] + + if (!updated) { + set.status = 404 + return { error: `${name} not found` } + } + + return updated + }, + { + params: t.Object({ + [primaryKey]: t.String(), + }), + body: t.Record(t.String(), t.Any()), + }, + ) + + // ── DELETE ──────────────────────────────────────────────────────── + routes.delete( + `/:${primaryKey}`, + async ({ params, request, set }) => { + const guard = await requireRole(request.headers, [authConfig.delete]) + + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const id = (params as any)[primaryKey] + + if (softDelete && columns["isRemoved"]) { + const [updated] = (await db + .update(table as any) + .set({ isRemoved: true, updatedAt: new Date() } as any) + .where(eq(pkColumn, id)) + .returning()) as any[] + + if (!updated) { + set.status = 404 + return { error: `${name} not found` } + } + + return { success: true } + } + + const [deleted] = (await db + .delete(table as any) + .where(eq(pkColumn, id)) + .returning()) as any[] + + if (!deleted) { + set.status = 404 + return { error: `${name} not found` } + } + + return { success: true } + }, + { + params: t.Object({ + [primaryKey]: t.String(), + }), + }, + ) + + return routes +}