diff --git a/app/api/capa/[id]/route.ts b/app/api/capa/[id]/route.ts index 94e60a0..3dceb78 100644 --- a/app/api/capa/[id]/route.ts +++ b/app/api/capa/[id]/route.ts @@ -1,9 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' -import { createAdminClient } from '@/lib/supabase/admin' import { getSession } from '@/lib/auth/get-session' +import { withUser, asAdmin } 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' export async function GET( _: NextRequest, @@ -13,31 +15,66 @@ export async function GET( const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const supabase = await createClient() + const ownerAlias = aliasedTable(users, 'owner') - const { data, error } = await supabase - .from('capa_actions') - .select(` - id, incident_id, root_cause_ref, description, owner_user_id, department, - due_date, priority, status, completed_at, verified_by, verified_at, created_at, - owner_notes, - incidents (reference_no, incident_type), - owner:users!owner_user_id (name, email) - `) - .eq('id', id) - .single() + const [row] = await withUser(session.sub, async tx => + tx + .select({ + id: capaActions.id, + incidentId: capaActions.incidentId, + rootCauseRef: capaActions.rootCauseRef, + description: capaActions.description, + ownerUserId: capaActions.ownerUserId, + department: capaActions.department, + dueDate: capaActions.dueDate, + priority: capaActions.priority, + status: capaActions.status, + completedAt: capaActions.completedAt, + verifiedBy: capaActions.verifiedBy, + verifiedAt: capaActions.verifiedAt, + createdAt: capaActions.createdAt, + ownerNotes: capaActions.ownerNotes, + incidentReferenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + ownerName: ownerAlias.name, + ownerEmail: ownerAlias.email, + }) + .from(capaActions) + .leftJoin(ownerAlias, eq(capaActions.ownerUserId, ownerAlias.id)) + .leftJoin(incidents, eq(capaActions.incidentId, incidents.id)) + .where(eq(capaActions.id, id)) + .limit(1) + ) - if (error || !data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 }) const role = session.role - const isOwner = data.owner_user_id === session.sub + const isOwner = row.ownerUserId === session.sub const canRead = ['hse', 'admin', 'supervisor'].includes(role) || isOwner if (!canRead) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - // Strip owner email for non-privileged roles — only name needed - const result = ['hse', 'admin'].includes(role) - ? data - : { ...data, owner: { name: (data.owner as unknown as { name: string } | null)?.name ?? '' } } + const ownerField = ['hse', 'admin'].includes(role) + ? { name: row.ownerName, email: row.ownerEmail } + : { name: row.ownerName ?? '' } + + const result = { + id: row.id, + incident_id: row.incidentId, + root_cause_ref: row.rootCauseRef, + description: row.description, + owner_user_id: row.ownerUserId, + department: row.department, + due_date: row.dueDate, + priority: row.priority, + status: row.status, + completed_at: row.completedAt, + verified_by: row.verifiedBy, + verified_at: row.verifiedAt, + created_at: row.createdAt, + owner_notes: row.ownerNotes, + incidents: { reference_no: row.incidentReferenceNo, incident_type: row.incidentType }, + owner: ownerField, + } return NextResponse.json(result) } @@ -50,11 +87,14 @@ export async function PATCH( const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const supabase = await createClient() const role = session.role - const { data: capa } = await supabase.from('capa_actions').select('owner_user_id, status').eq('id', id).single() - const isOwner = capa?.owner_user_id === session.sub + const [capa] = await withUser(session.sub, async tx => + tx.select({ ownerUserId: capaActions.ownerUserId, status: capaActions.status }) + .from(capaActions).where(eq(capaActions.id, id)).limit(1) + ) + + const isOwner = capa?.ownerUserId === session.sub const canEdit = ['hse', 'admin'].includes(role) || isOwner if (!canEdit) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) @@ -65,7 +105,6 @@ export async function PATCH( const body = await request.json() - // Only hse/admin can set privileged statuses const privilegedStatuses = ['verified', 'reopened', 'closed'] if (body.status && privilegedStatuses.includes(body.status) && !['hse', 'admin'].includes(role)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) @@ -74,36 +113,44 @@ export async function PATCH( const allowed = ['hse', 'admin'].includes(role) ? ['description', 'due_date', 'priority', 'status', 'department', 'root_cause_ref', 'owner_notes'] : ['status', 'owner_notes'] - const update: Record = {} - for (const key of allowed) { - if (key in body) update[key] = body[key] - } - if (body.status === 'pending_verification') { - update.completed_at = new Date().toISOString() - } + const updateSet: Partial = {} + if ('description' in body && allowed.includes('description')) updateSet.description = body.description + if ('due_date' in body && allowed.includes('due_date')) updateSet.dueDate = body.due_date + if ('priority' in body && allowed.includes('priority')) updateSet.priority = body.priority + if ('status' in body && allowed.includes('status')) updateSet.status = body.status + if ('department' in body && allowed.includes('department')) updateSet.department = body.department + if ('root_cause_ref' in body && allowed.includes('root_cause_ref')) updateSet.rootCauseRef = body.root_cause_ref + 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 update) { - const { data: current } = await supabase.from('capa_actions').select('owner_notes').eq('id', id).single() - oldNotes = (current as { owner_notes: string | null } | null)?.owner_notes ?? 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 } - const admin = createAdminClient() - const { error } = await admin - .from('capa_actions') - .update(update) - .eq('id', id) + await asAdmin(db => db.update(capaActions).set(updateSet).where(eq(capaActions.id, id))) - if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) + // Build audit new/old value from original snake_case body keys for consistency + const auditNew: Record = {} + for (const key of allowed) { + if (key in body) auditNew[key] = body[key] + } + if (body.status === 'pending_verification') auditNew.completed_at = updateSet.completedAt?.toISOString() - await supabase.rpc('write_audit_log', { - p_table_name: 'capa_actions', - p_record_id: id, - p_action: 'updated', - p_old_value: oldNotes !== null ? { owner_notes: oldNotes } : undefined, - p_new_value: update as Record, - }) + await withUser(session.sub, async tx => + writeAuditLog( + tx, + 'capa_actions', + id, + 'updated', + auditNew, + oldNotes !== null ? { owner_notes: oldNotes } : undefined, + ) + ) return NextResponse.json({ ok: true }) } diff --git a/app/api/capa/[id]/verify/route.ts b/app/api/capa/[id]/verify/route.ts index f29eb20..77e4cf7 100644 --- a/app/api/capa/[id]/verify/route.ts +++ b/app/api/capa/[id]/verify/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { capaActions, incidents } from '@/lib/db/schema' +import { eq, and, not, inArray } from 'drizzle-orm' import { createInAppNotifications } from '@/lib/notifications/in-app' export async function POST( @@ -15,10 +18,10 @@ export async function POST( if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - - const { data: capa } = await supabase - .from('capa_actions').select('status, incident_id, owner_user_id').eq('id', id).single() + const [capa] = await withUser(session.sub, async tx => + tx.select({ status: capaActions.status, incidentId: capaActions.incidentId, ownerUserId: capaActions.ownerUserId }) + .from(capaActions).where(eq(capaActions.id, id)).limit(1) + ) if (!capa) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (capa.status !== 'pending_verification') return NextResponse.json({ error: 'CAPA must be pending_verification' }, { status: 409 }) @@ -31,54 +34,47 @@ export async function POST( const recheckDate = new Date(verifiedAt) recheckDate.setUTCDate(recheckDate.getUTCDate() + 30) - const update: Record = { - status: body.verdict, - verified_by: session.sub, - verified_at: verifiedAt.toISOString(), - ...(body.verdict === 'verified' - ? { - effectiveness_recheck_date: recheckDate.toISOString().split('T')[0], - effectiveness_recheck_round: 0, - } - : {}), - } + await withUser(session.sub, async tx => { + await tx.update(capaActions).set({ + status: body.verdict, + verifiedBy: session.sub, + verifiedAt, + ...(body.verdict === 'verified' ? { + effectivenessRecheckDate: recheckDate.toISOString().split('T')[0], + effectivenessRecheckRound: 0, + } : {}), + }).where(eq(capaActions.id, id)) - const { error } = await supabase - .from('capa_actions').update(update).eq('id', id) - - if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) - - await supabase.rpc('write_audit_log', { - p_table_name: 'capa_actions', - p_record_id: id, - p_action: body.verdict === 'verified' ? 'verified' : 'reopened', - p_new_value: { ...update, reopen_reason: body.reopen_reason ?? null }, + await writeAuditLog(tx, 'capa_actions', id, body.verdict === 'verified' ? 'verified' : 'reopened', { + status: body.verdict, reopen_reason: body.reopen_reason ?? null, + }) }) - if (capa.owner_user_id) { + if (capa.ownerUserId) { await createInAppNotifications([{ - userId: capa.owner_user_id, + userId: capa.ownerUserId, title: body.verdict === 'verified' ? 'Your CAPA action was verified' : `Your CAPA action was reopened${body.reopen_reason ? `: ${body.reopen_reason}` : ''}`, link: `/hse/capa/${id}`, - incidentId: capa.incident_id, + incidentId: capa.incidentId, capaId: id, }]) } - // Check if all CAPAs for this incident are verified — if so, transition incident to verification - const { data: openCapas } = await supabase - .from('capa_actions') - .select('id') - .eq('incident_id', capa.incident_id) - .not('status', 'in', '(verified,closed)') + const openCapas = await withUser(session.sub, async tx => + tx.select({ id: capaActions.id }).from(capaActions) + .where(and( + eq(capaActions.incidentId, capa.incidentId), + not(inArray(capaActions.status, ['verified', 'closed'])), + )) + ) - if (!openCapas || openCapas.length === 0) { - await supabase - .from('incidents') - .update({ status: 'verification' }) - .eq('id', capa.incident_id) + if (openCapas.length === 0) { + await withUser(session.sub, async tx => { + await tx.update(incidents).set({ status: 'verification' }).where(eq(incidents.id, capa.incidentId)) + await writeAuditLog(tx, 'incidents', capa.incidentId, 'status_changed', { status: 'verification' }) + }) } return NextResponse.json({ ok: true }) diff --git a/app/api/capa/route.ts b/app/api/capa/route.ts index 66e4aeb..40c9cae 100644 --- a/app/api/capa/route.ts +++ b/app/api/capa/route.ts @@ -1,33 +1,71 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { capaActions, incidents, users } from '@/lib/db/schema' +import { aliasedTable, eq, asc } from 'drizzle-orm' import { createInAppNotifications } from '@/lib/notifications/in-app' export async function GET() { const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const supabase = await createClient() + const ownerAlias = aliasedTable(users, 'owner') - let query = supabase - .from('capa_actions') - .select(` - id, incident_id, root_cause_ref, description, owner_user_id, department, - due_date, priority, status, completed_at, verified_by, verified_at, created_at, - incidents (reference_no, incident_type), - owner:users!owner_user_id (name, email) - `) - .order('due_date', { ascending: true }) + const rows = await withUser(session.sub, async tx => { + let q = tx + .select({ + id: capaActions.id, + incidentId: capaActions.incidentId, + rootCauseRef: capaActions.rootCauseRef, + description: capaActions.description, + ownerUserId: capaActions.ownerUserId, + department: capaActions.department, + dueDate: capaActions.dueDate, + priority: capaActions.priority, + status: capaActions.status, + completedAt: capaActions.completedAt, + verifiedBy: capaActions.verifiedBy, + verifiedAt: capaActions.verifiedAt, + createdAt: capaActions.createdAt, + incidentReferenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + ownerName: ownerAlias.name, + ownerEmail: ownerAlias.email, + }) + .from(capaActions) + .leftJoin(ownerAlias, eq(capaActions.ownerUserId, ownerAlias.id)) + .leftJoin(incidents, eq(capaActions.incidentId, incidents.id)) + .orderBy(asc(capaActions.dueDate)) - if (session.role === 'supervisor' || session.role === 'worker') { - query = query.eq('owner_user_id', session.sub) - } + if (session.role === 'supervisor' || session.role === 'worker') { + q = q.where(eq(capaActions.ownerUserId, session.sub)) as typeof q + } - const { data, error } = await query - if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 }) - return NextResponse.json(data ?? []) + return q + }) + + const data = rows.map(r => ({ + id: r.id, + incident_id: r.incidentId, + root_cause_ref: r.rootCauseRef, + description: r.description, + owner_user_id: r.ownerUserId, + department: r.department, + due_date: r.dueDate, + priority: r.priority, + status: r.status, + completed_at: r.completedAt, + verified_by: r.verifiedBy, + verified_at: r.verifiedAt, + created_at: r.createdAt, + incidents: { reference_no: r.incidentReferenceNo, incident_type: r.incidentType }, + owner: { name: r.ownerName, email: r.ownerEmail }, + })) + + return NextResponse.json(data) } export async function POST(request: NextRequest) { @@ -36,44 +74,43 @@ export async function POST(request: NextRequest) { if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - const body = await request.json() const { incident_id, description, owner_user_id, department, due_date, priority, root_cause_ref } = body if (!incident_id || !description || !owner_user_id || !due_date) return NextResponse.json({ error: 'Missing required fields' }, { status: 422 }) - const { data: capa, error } = await supabase - .from('capa_actions') - .insert({ - incident_id, - description, - owner_user_id, - department: department || '', - due_date, - priority: priority ?? 'med', - root_cause_ref: root_cause_ref ?? null, + let capaId!: string + + await withUser(session.sub, async tx => { + const [capa] = await tx + .insert(capaActions) + .values({ + incidentId: incident_id, + description, + ownerUserId: owner_user_id, + department: department || '', + dueDate: due_date, + priority: priority ?? 'med', + rootCauseRef: root_cause_ref ?? null, + }) + .returning({ id: capaActions.id }) + + if (!capa) throw new Error('Insert failed') + capaId = capa.id + + await writeAuditLog(tx, 'capa_actions', capa.id, 'created', { + incident_id, description, owner_user_id, department, due_date, }) - .select('id') - .single() - - if (error || !capa) return NextResponse.json({ error: 'Insert failed' }, { status: 500 }) - - await supabase.rpc('write_audit_log', { - p_table_name: 'capa_actions', - p_record_id: capa.id, - p_action: 'created', - p_new_value: { incident_id, description, owner_user_id, department, due_date }, }) await createInAppNotifications([{ userId: owner_user_id, title: `CAPA assigned to you, due ${due_date}`, - link: `/hse/capa/${capa.id}`, + link: `/hse/capa/${capaId}`, incidentId: incident_id, - capaId: capa.id, + capaId, }]) - return NextResponse.json({ id: capa.id }, { status: 201 }) + return NextResponse.json({ id: capaId }, { status: 201 }) }