301 lines
8.0 KiB
TypeScript
301 lines
8.0 KiB
TypeScript
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<T extends AnyPgTable>(
|
|
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<string, PgColumn>
|
|
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<number>`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<string, string[]> = {
|
|
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<string, string[]> = {
|
|
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
|
|
}
|