From 9c67014bb99ca24e39bf91ef499bbef497a742f7 Mon Sep 17 00:00:00 2001 From: weeihan Date: Fri, 24 Jul 2026 06:44:05 +0800 Subject: [PATCH] fix(audit): wrap admin + capa mutations with their audit writes in single transactions Co-Authored-By: Claude Sonnet 4.6 --- app/api/admin/sites/route.ts | 64 ++++++++++++++----------- app/api/admin/trucks/route.ts | 44 ++++++++++------- app/api/admin/users/route.ts | 90 +++++++++++++++++++---------------- app/api/capa/[id]/route.ts | 30 ++++++------ app/api/settings/route.ts | 31 ++++++------ 5 files changed, 145 insertions(+), 114 deletions(-) diff --git a/app/api/admin/sites/route.ts b/app/api/admin/sites/route.ts index 6f39b5c..3903191 100644 --- a/app/api/admin/sites/route.ts +++ b/app/api/admin/sites/route.ts @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { requireAdmin } from '@/lib/auth/require-admin' -import { asAdmin, withUser } from '@/lib/db/with-user' +import { asAdmin } from '@/lib/db/with-user' import { sites, zones, incidents } from '@/lib/db/schema' import { writeAuditLog } from '@/lib/db/audit' import { eq, sql } from 'drizzle-orm' @@ -20,13 +20,17 @@ export async function POST(request: NextRequest) { if (!body.site_id) return NextResponse.json({ error: 'site_id required for zone' }, { status: 422 }) const qrCodeToken = crypto.randomUUID() try { - const [zone] = await asAdmin(db => - db.insert(zones).values({ siteId: body.site_id!, name, qrCodeToken }).returning({ id: zones.id, qrCodeToken: zones.qrCodeToken }) - ) - if (!zone) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) - await withUser(session.sub, async tx => { - await writeAuditLog(tx, 'zones', zone.id, 'INSERT', { name, site_id: body.site_id }) + let zone: { id: string; qrCodeToken: string } | undefined + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + const [result] = await tx.insert(zones).values({ siteId: body.site_id!, name, qrCodeToken }).returning({ id: zones.id, qrCodeToken: zones.qrCodeToken }) + if (!result) throw new Error('Insert failed') + zone = result + await writeAuditLog(tx, 'zones', result.id, 'INSERT', { name, site_id: body.site_id }) + }) }) + if (!zone) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) return NextResponse.json({ id: zone.id, qr_code_token: zone.qrCodeToken }, { status: 201 }) } catch { return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) @@ -34,14 +38,18 @@ export async function POST(request: NextRequest) { } try { - const [site] = await asAdmin(db => - db.insert(sites).values({ name, address: body.address ?? null }).returning({ id: sites.id }) - ) - if (!site) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) - await withUser(session.sub, async tx => { - await writeAuditLog(tx, 'sites', site.id, 'INSERT', { name, address: body.address ?? null }) + let siteId: string | undefined + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + const [site] = await tx.insert(sites).values({ name, address: body.address ?? null }).returning({ id: sites.id }) + if (!site) throw new Error('Insert failed') + siteId = site.id + await writeAuditLog(tx, 'sites', site.id, 'INSERT', { name, address: body.address ?? null }) + }) }) - return NextResponse.json({ id: site.id }, { status: 201 }) + if (!siteId) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) + return NextResponse.json({ id: siteId }, { status: 201 }) } catch { return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) } @@ -59,17 +67,18 @@ export async function PATCH(request: NextRequest) { const [before] = await asAdmin(db => db.select().from(table).where(eq(table.id, body.id!)).limit(1) ) + const tableName = body.kind === 'zone' ? 'zones' : 'sites' try { - await asAdmin(db => - db.update(table).set({ active: body.active }).where(eq(table.id, body.id!)) - ) + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + await tx.update(table).set({ active: body.active }).where(eq(table.id, body.id!)) + await writeAuditLog(tx, tableName, body.id!, 'admin_update', { active: body.active }, before ?? null) + }) + }) } catch { return NextResponse.json({ error: 'Update failed' }, { status: 500 }) } - const tableName = body.kind === 'zone' ? 'zones' : 'sites' - await withUser(session.sub, async tx => { - await writeAuditLog(tx, tableName, body.id!, 'admin_update', { active: body.active }, before ?? null) - }) return NextResponse.json({ ok: true }) } @@ -97,9 +106,13 @@ export async function DELETE(request: NextRequest) { db.select().from(body.kind === 'zone' ? zones : sites).where(eq((body.kind === 'zone' ? zones : sites).id, body.id!)).limit(1) ) try { - await asAdmin(db => - db.delete(body.kind === 'zone' ? zones : sites).where(eq((body.kind === 'zone' ? zones : sites).id, body.id!)) - ) + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + await tx.delete(body.kind === 'zone' ? zones : sites).where(eq((body.kind === 'zone' ? zones : sites).id, body.id!)) + await writeAuditLog(tx, tableName, body.id!, 'DELETE', null, before ?? null) + }) + }) } catch (e) { const code = (e as { code?: string }).code return NextResponse.json( @@ -107,8 +120,5 @@ export async function DELETE(request: NextRequest) { { status: code === '23503' ? 409 : 500 }, ) } - await withUser(session.sub, async tx => { - await writeAuditLog(tx, tableName, body.id!, 'DELETE', null, before ?? null) - }) return NextResponse.json({ ok: true }) } diff --git a/app/api/admin/trucks/route.ts b/app/api/admin/trucks/route.ts index 723742d..5469683 100644 --- a/app/api/admin/trucks/route.ts +++ b/app/api/admin/trucks/route.ts @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { requireAdmin } from '@/lib/auth/require-admin' -import { asAdmin, withUser } from '@/lib/db/with-user' +import { asAdmin } from '@/lib/db/with-user' import { trucks, incidents } from '@/lib/db/schema' import { writeAuditLog } from '@/lib/db/audit' import { eq, sql } from 'drizzle-orm' @@ -16,14 +16,18 @@ export async function POST(request: NextRequest) { if (!truck_no) return NextResponse.json({ error: 'truck_no required' }, { status: 422 }) try { - const [truck] = await asAdmin(db => - db.insert(trucks).values({ truckNo: truck_no, carrier: (body.carrier ?? '').trim() || null }).returning({ id: trucks.id }) - ) - if (!truck) return NextResponse.json({ error: 'Insert failed' }, { status: 400 }) - await withUser(session.sub, async tx => { - await writeAuditLog(tx, 'trucks', truck.id, 'INSERT', { truck_no, carrier: (body.carrier ?? '').trim() || null }) + let truckId: string | undefined + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + const [truck] = await tx.insert(trucks).values({ truckNo: truck_no, carrier: (body.carrier ?? '').trim() || null }).returning({ id: trucks.id }) + if (!truck) throw new Error('Insert failed') + truckId = truck.id + await writeAuditLog(tx, 'trucks', truck.id, 'INSERT', { truck_no, carrier: (body.carrier ?? '').trim() || null }) + }) }) - return NextResponse.json({ id: truck.id }, { status: 201 }) + if (!truckId) return NextResponse.json({ error: 'Insert failed' }, { status: 400 }) + return NextResponse.json({ id: truckId }, { status: 201 }) } catch (e) { const code = (e as { code?: string }).code return NextResponse.json( @@ -44,15 +48,16 @@ export async function PATCH(request: NextRequest) { db.select().from(trucks).where(eq(trucks.id, body.id!)).limit(1) ) try { - await asAdmin(db => - db.update(trucks).set({ active: body.active }).where(eq(trucks.id, body.id!)) - ) + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + await tx.update(trucks).set({ active: body.active }).where(eq(trucks.id, body.id!)) + await writeAuditLog(tx, 'trucks', body.id!, 'admin_update', { active: body.active }, before ?? null) + }) + }) } catch { return NextResponse.json({ error: 'Update failed' }, { status: 500 }) } - await withUser(session.sub, async tx => { - await writeAuditLog(tx, 'trucks', body.id!, 'admin_update', { active: body.active }, before ?? null) - }) return NextResponse.json({ ok: true }) } @@ -77,7 +82,13 @@ export async function DELETE(request: NextRequest) { db.select().from(trucks).where(eq(trucks.id, body.id!)).limit(1) ) try { - await asAdmin(db => db.delete(trucks).where(eq(trucks.id, body.id!))) + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + await tx.delete(trucks).where(eq(trucks.id, body.id!)) + await writeAuditLog(tx, 'trucks', body.id!, 'DELETE', null, before ?? null) + }) + }) } catch (e) { const code = (e as { code?: string }).code return NextResponse.json( @@ -85,8 +96,5 @@ export async function DELETE(request: NextRequest) { { status: code === '23503' ? 409 : 500 }, ) } - await withUser(session.sub, async tx => { - await writeAuditLog(tx, 'trucks', body.id!, 'DELETE', null, before ?? null) - }) return NextResponse.json({ ok: true }) } diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 025b89c..408a58c 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -4,10 +4,10 @@ import { NextRequest, NextResponse } from 'next/server' import { isValidRole } from '@/lib/auth/roles' import { requireAdmin } from '@/lib/auth/require-admin' import { hashPassword } from '@/lib/auth/password' -import { asAdmin, withUser } from '@/lib/db/with-user' +import { asAdmin } from '@/lib/db/with-user' import { users } from '@/lib/db/schema' import { writeAuditLog } from '@/lib/db/audit' -import { desc, eq } from 'drizzle-orm' +import { desc, eq, sql } from 'drizzle-orm' export async function GET() { const { session } = await requireAdmin() @@ -61,28 +61,33 @@ export async function POST(request: NextRequest) { const passwordHash = await hashPassword(password) - const [created] = await asAdmin(db => db.insert(users).values({ - name: body.name ?? '', - email, - phone: (body.phone ?? '').trim() || null, - role: (body.role as typeof users.$inferInsert['role']) ?? 'reporter', - siteId: body.site_id ?? null, - department: body.department ?? null, - passwordHash, - emailVerifiedAt: new Date(), - }).returning({ id: users.id })) - - if (!created) { - return NextResponse.json({ error: 'User creation failed' }, { status: 500 }) - } - - await withUser(session.sub, async tx => { - await writeAuditLog(tx, 'users', created.id, 'created', { - email, role: body.role ?? 'reporter', site_id: body.site_id ?? null, + let createdId: string | undefined + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + const [created] = await tx.insert(users).values({ + name: body.name ?? '', + email, + phone: (body.phone ?? '').trim() || null, + role: (body.role as typeof users.$inferInsert['role']) ?? 'reporter', + siteId: body.site_id ?? null, + department: body.department ?? null, + passwordHash, + emailVerifiedAt: new Date(), + }).returning({ id: users.id }) + if (!created) throw new Error('User creation failed') + createdId = created.id + await writeAuditLog(tx, 'users', created.id, 'created', { + email, role: body.role ?? 'reporter', site_id: body.site_id ?? null, + }) }) }) - return NextResponse.json({ id: created.id }, { status: 201 }) + if (!createdId) { + return NextResponse.json({ error: 'User creation failed' }, { status: 500 }) + } + + return NextResponse.json({ id: createdId }, { status: 201 }) } export async function PATCH(request: NextRequest) { @@ -122,17 +127,20 @@ export async function PATCH(request: NextRequest) { if (!before) return NextResponse.json({ error: 'User not found' }, { status: 404 }) try { - await asAdmin(db => db.update(users).set(drizzleUpdate).where(eq(users.id, body.id!))) + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + await tx.update(users).set(drizzleUpdate).where(eq(users.id, body.id!)) + await writeAuditLog( + tx, 'users', body.id!, 'admin_update', + drizzleUpdate, + { role: before.role, site_id: before.siteId, active: before.active, department: before.department }, + ) + }) + }) } catch { return NextResponse.json({ error: 'Update failed' }, { status: 500 }) } - await withUser(session.sub, async tx => { - await writeAuditLog( - tx, 'users', body.id!, 'admin_update', - drizzleUpdate, - { role: before.role, site_id: before.siteId, active: before.active, department: before.department }, - ) - }) return NextResponse.json({ ok: true }) } @@ -153,21 +161,23 @@ export async function DELETE(request: NextRequest) { ) // Delete user directly from DB - const [deleted] = await asAdmin(db => - db.delete(users).where(eq(users.id, id)).returning({ id: users.id }) - ) + let deleted: { id: string } | undefined + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + const [result] = await tx.delete(users).where(eq(users.id, id)).returning({ id: users.id }) + deleted = result + if (result && target) { + await writeAuditLog(tx, 'users', id, 'deleted', { + email: target.email, name: target.name, role: target.role, + }) + } + }) + }) if (!deleted) { return NextResponse.json({ error: 'User not found or deletion failed' }, { status: 500 }) } - if (target) { - await withUser(session.sub, async tx => { - await writeAuditLog(tx, 'users', id, 'deleted', { - email: target.email, name: target.name, role: target.role, - }) - }) - } - return NextResponse.json({ ok: true }) } diff --git a/app/api/capa/[id]/route.ts b/app/api/capa/[id]/route.ts index 3dceb78..a9ae2ed 100644 --- a/app/api/capa/[id]/route.ts +++ b/app/api/capa/[id]/route.ts @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth/get-session' -import { withUser, asAdmin } from '@/lib/db/with-user' +import { withUser } from '@/lib/db/with-user' import { writeAuditLog } from '@/lib/db/audit' import { capaActions, incidents, users } from '@/lib/db/schema' import { aliasedTable, eq } from 'drizzle-orm' @@ -124,16 +124,6 @@ export async function PATCH( if ('owner_notes' in body && allowed.includes('owner_notes')) updateSet.ownerNotes = body.owner_notes if (body.status === 'pending_verification') updateSet.completedAt = new Date() - let oldNotes: string | null = null - if ('owner_notes' in body && allowed.includes('owner_notes')) { - const [current] = await withUser(session.sub, async tx => - tx.select({ ownerNotes: capaActions.ownerNotes }).from(capaActions).where(eq(capaActions.id, id)).limit(1) - ) - oldNotes = current?.ownerNotes ?? null - } - - await asAdmin(db => db.update(capaActions).set(updateSet).where(eq(capaActions.id, id))) - // Build audit new/old value from original snake_case body keys for consistency const auditNew: Record = {} for (const key of allowed) { @@ -141,8 +131,20 @@ export async function PATCH( } if (body.status === 'pending_verification') auditNew.completed_at = updateSet.completedAt?.toISOString() - await withUser(session.sub, async tx => - writeAuditLog( + await withUser(session.sub, async tx => { + let oldNotes: string | null = null + if ('owner_notes' in body && allowed.includes('owner_notes')) { + const [current] = await tx + .select({ ownerNotes: capaActions.ownerNotes }) + .from(capaActions) + .where(eq(capaActions.id, id)) + .limit(1) + oldNotes = current?.ownerNotes ?? null + } + + await tx.update(capaActions).set(updateSet).where(eq(capaActions.id, id)) + + await writeAuditLog( tx, 'capa_actions', id, @@ -150,7 +152,7 @@ export async function PATCH( auditNew, oldNotes !== null ? { owner_notes: oldNotes } : undefined, ) - ) + }) return NextResponse.json({ ok: true }) } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index e26a421..a454b91 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -2,7 +2,8 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth/get-session' -import { asAdmin, withUser } from '@/lib/db/with-user' +import { sql } from 'drizzle-orm' +import { asAdmin } from '@/lib/db/with-user' import { writeAuditLog } from '@/lib/db/audit' import { appSettings } from '@/lib/db/schema' @@ -47,21 +48,21 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'value must be a non-empty string' }, { status: 422 }) } - await asAdmin(db => - db.insert(appSettings).values({ - key: body.key!, - value: body.value!, - updatedAt: new Date(), - updatedBy: session.sub, - }).onConflictDoUpdate({ - target: appSettings.key, - set: { value: body.value!, updatedAt: new Date(), updatedBy: session.sub }, + await asAdmin(async db => { + await db.transaction(async tx => { + await tx.execute(sql`SELECT set_config('app.user_id', ${session.sub}, true)`) + await tx.insert(appSettings).values({ + key: body.key!, + value: body.value!, + updatedAt: new Date(), + updatedBy: session.sub, + }).onConflictDoUpdate({ + target: appSettings.key, + set: { value: body.value!, updatedAt: new Date(), updatedBy: session.sub }, + }) + await writeAuditLog(tx, 'app_settings', session.sub, 'UPDATE', { key: body.key, set: Boolean(body.value) }) }) - ) - - await withUser(session.sub, async tx => - writeAuditLog(tx, 'app_settings', session.sub, 'UPDATE', { key: body.key, set: Boolean(body.value) }) - ) + }) return NextResponse.json({ ok: true }) }