feat(api): add reusable CRUD route factory with auth, pagination, search, and filtering
This commit is contained in:
@@ -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<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
|
||||
}
|
||||
Reference in New Issue
Block a user