Files
ims/app/api/admin/users/route.ts
T
adminandClaude Sonnet 4.6 d234ebf916 feat(db): phase 4 group 2 — admin routes to Drizzle
Convert app/api/admin/sites, trucks, users from Supabase PostgREST to
Drizzle ORM. All data ops use asAdmin(); all audit writes use
withUser(session.sub, tx => writeAuditLog(tx, ...)). Zero supabase
imports remain in the three files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 16:44:39 +08:00

174 lines
6.3 KiB
TypeScript

export const dynamic = 'force-dynamic'
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 { users } from '@/lib/db/schema'
import { writeAuditLog } from '@/lib/db/audit'
import { desc, eq } from 'drizzle-orm'
export async function GET() {
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const data = await asAdmin(db =>
db.select({
id: users.id,
name: users.name,
email: users.email,
phone: users.phone,
role: users.role,
department: users.department,
siteId: users.siteId,
active: users.active,
createdAt: users.createdAt,
})
.from(users)
.orderBy(desc(users.createdAt))
)
// Return snake_case to preserve existing frontend contract
return NextResponse.json(data.map(u => ({
id: u.id,
name: u.name,
email: u.email,
phone: u.phone,
role: u.role,
department: u.department,
site_id: u.siteId,
active: u.active,
created_at: u.createdAt,
})))
}
export async function POST(request: NextRequest) {
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: { email?: string; name?: string; phone?: string; role?: string; site_id?: string; department?: string; password?: string } =
await request.json().catch(() => ({}))
const email = (body.email ?? '').trim().toLowerCase()
if (!email || !email.includes('@'))
return NextResponse.json({ error: 'Valid email required' }, { status: 422 })
if (body.role && !isValidRole(body.role))
return NextResponse.json({ error: 'Invalid role' }, { status: 422 })
const password = (body.password ?? '').trim()
if (!password)
return NextResponse.json({ error: 'Password required' }, { status: 422 })
if (password.length < 8)
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 422 })
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,
})
})
return NextResponse.json({ id: created.id }, { status: 201 })
}
export async function PATCH(request: NextRequest) {
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: {
id?: string
role?: string
site_id?: string | null
active?: boolean
department?: string | null
} = await request.json().catch(() => ({}))
if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 })
if (body.role !== undefined && !isValidRole(body.role))
return NextResponse.json({ error: 'Invalid role' }, { status: 422 })
if (body.id === session.sub && body.active === false)
return NextResponse.json({ error: 'Cannot deactivate your own account' }, { status: 422 })
if (body.id === session.sub && body.role !== undefined && body.role !== 'admin')
return NextResponse.json({ error: 'Cannot demote your own account' }, { status: 422 })
const drizzleUpdate: Partial<{ role: typeof users.$inferInsert['role']; siteId: string | null; active: boolean; department: string | null }> = {}
if (body.role !== undefined) drizzleUpdate.role = body.role as typeof users.$inferInsert['role']
if (body.site_id !== undefined) drizzleUpdate.siteId = body.site_id
if (body.active !== undefined) drizzleUpdate.active = body.active
if (body.department !== undefined) drizzleUpdate.department = body.department
if (Object.keys(drizzleUpdate).length === 0)
return NextResponse.json({ error: 'Nothing to update' }, { status: 422 })
const [before] = await asAdmin(db =>
db.select({ role: users.role, siteId: users.siteId, active: users.active, department: users.department })
.from(users)
.where(eq(users.id, body.id!))
.limit(1)
)
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!)))
} 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 })
}
export async function DELETE(request: NextRequest) {
const { session } = await requireAdmin()
if (!session) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'id required' }, { status: 422 })
if (id === session.sub)
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 422 })
// fetch user info for audit before deletion
const [target] = await asAdmin(db =>
db.select({ email: users.email, name: users.name, role: users.role })
.from(users).where(eq(users.id, id)).limit(1)
)
// Delete user directly from DB
const [deleted] = await asAdmin(db =>
db.delete(users).where(eq(users.id, id)).returning({ id: users.id })
)
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 })
}