diff --git a/app/api/dashboard/ai/risk-flags/route.ts b/app/api/dashboard/ai/risk-flags/route.ts index c027420..88d4c77 100644 --- a/app/api/dashboard/ai/risk-flags/route.ts +++ b/app/api/dashboard/ai/risk-flags/route.ts @@ -1,8 +1,11 @@ export const dynamic = 'force-dynamic' import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' +import { withUser, asAdmin } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { incidents, sites, zones, auditLog } from '@/lib/db/schema' +import { eq, and, gte, desc } from 'drizzle-orm' import { createDeepSeekClient } from '@/lib/claude/client' import { getApiKey } from '@/lib/settings' @@ -24,27 +27,33 @@ export async function POST() { if (!['hse', 'admin', 'management'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - const now = new Date() const ninetyDaysAgo = new Date(now) ninetyDaysAgo.setDate(now.getDate() - 90) const midpoint = new Date(now) midpoint.setDate(now.getDate() - 45) - const { data: incidents } = await supabase - .from('incidents') - .select('incident_type, severity, reported_at, zones (name), sites (name)') - .gte('reported_at', ninetyDaysAgo.toISOString()) + const rows = await withUser(session.sub, async tx => + tx.select({ + incidentType: incidents.incidentType, + severity: incidents.severity, + reportedAt: incidents.reportedAt, + zoneName: zones.name, + siteName: sites.name, + }) + .from(incidents) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .where(gte(incidents.reportedAt, ninetyDaysAgo)) + ) - const rows = incidents ?? [] if (rows.length === 0) return NextResponse.json({ flags: [], summary: 'No incidents in the last 90 days.' }) const zoneMap = new Map() for (const r of rows) { - const zone = (r.zones as unknown as { name: string } | null)?.name ?? 'Unknown zone' - const site = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown site' + const zone = r.zoneName ?? 'Unknown zone' + const site = r.siteName ?? 'Unknown site' const key = `${site}|${zone}` let agg = zoneMap.get(key) if (!agg) { @@ -55,14 +64,14 @@ export async function POST() { zoneMap.set(key, agg) } agg.total++ - if (r.incident_type === 'near_miss') agg.near_miss++ - if (r.incident_type === 'hazard') agg.hazard++ - if (r.incident_type === 'injury') agg.injury++ + if (r.incidentType === 'near_miss') agg.near_miss++ + if (r.incidentType === 'hazard') agg.hazard++ + if (r.incidentType === 'injury') agg.injury++ if (typeof r.severity === 'number') { agg.severitySum += r.severity agg.severityCount++ } - if (new Date(r.reported_at as string) < midpoint) agg.first_half++ + if (r.reportedAt && new Date(r.reportedAt) < midpoint) agg.first_half++ else agg.second_half++ } @@ -79,16 +88,18 @@ export async function POST() { })) // Rate limit: 1 AI call per 60s per user (checked via audit_log) - const { data: lastCall } = await supabase - .from('audit_log') - .select('changed_at') - .eq('changed_by', session.sub) - .eq('action', 'ai_risk_flags') - .order('changed_at', { ascending: false }) - .limit(1) - .single() + const [lastCall] = await asAdmin(db => + db.select({ changedAt: auditLog.changedAt }) + .from(auditLog) + .where(and( + eq(auditLog.changedBy, session.sub), + eq(auditLog.action, 'ai_risk_flags'), + )) + .orderBy(desc(auditLog.changedAt)) + .limit(1) + ) - if (lastCall && Date.now() - new Date(lastCall.changed_at).getTime() < 60_000) { + if (lastCall && Date.now() - new Date(lastCall.changedAt!).getTime() < 60_000) { return NextResponse.json({ error: 'Rate limit: wait 60 seconds between AI requests' }, { status: 429 }) } @@ -164,12 +175,10 @@ ${JSON.stringify(aggregates, null, 2)} typeof (f as Record).recommended_action === 'string', ) - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: session.sub, - p_action: 'ai_risk_flags', - p_new_value: { flags, summary: input.summary, model: 'deepseek-chat' } as never, - }) + await withUser(session.sub, async tx => + writeAuditLog(tx, 'incidents', session.sub, 'ai_risk_flags', + { flags, summary: input.summary, model: 'deepseek-chat' }) + ) return NextResponse.json({ flags, summary: input.summary }) } diff --git a/app/api/dashboard/export/route.ts b/app/api/dashboard/export/route.ts index 222cdac..378bdf5 100644 --- a/app/api/dashboard/export/route.ts +++ b/app/api/dashboard/export/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 { incidents, sites, zones } from '@/lib/db/schema' +import { eq, desc } from 'drizzle-orm' import { rowsToCsv } from '@/lib/csv' export async function GET(request: NextRequest) { @@ -20,53 +23,53 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const supabase = await createClient() - - const { data: incidents } = await supabase - .from('incidents') - .select(` - reference_no, incident_type, status, severity, reported_at, closed_at, - injury_involved, medical_status, lost_days, - sites (name), zones (name) - `) - .order('reported_at', { ascending: false }) + const rows = await withUser(session.sub, async tx => + tx.select({ + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + status: incidents.status, + severity: incidents.severity, + reportedAt: incidents.reportedAt, + closedAt: incidents.closedAt, + injuryInvolved: incidents.injuryInvolved, + medicalStatus: incidents.medicalStatus, + lostDays: incidents.lostDays, + siteName: sites.name, + zoneName: zones.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .orderBy(desc(incidents.reportedAt)) .limit(10000) - - const rows = incidents ?? [] + ) const headers = [ 'Reference', 'Type', 'Status', 'Severity', 'Site', 'Zone', 'Reported At', 'Closed At', 'Injury Involved', 'Medical Status', 'Lost Days', ] - const csvRows = rows.map(inc => { - const siteName = (inc.sites as unknown as { name: string } | null)?.name ?? '' - const zoneName = (inc.zones as unknown as { name: string } | null)?.name ?? '' - return [ - inc.reference_no ?? '', - inc.incident_type, - inc.status, - String(inc.severity ?? ''), - siteName, - zoneName, - inc.reported_at ? new Date(inc.reported_at as string).toISOString().split('T')[0] : '', - inc.closed_at ? new Date(inc.closed_at as string).toISOString().split('T')[0] : '', - inc.injury_involved ? 'Yes' : 'No', - inc.medical_status ?? '', - String(inc.lost_days ?? ''), - ] - }) + const csvRows = rows.map(inc => [ + inc.referenceNo ?? '', + inc.incidentType, + inc.status, + String(inc.severity ?? ''), + inc.siteName ?? '', + inc.zoneName ?? '', + inc.reportedAt ? new Date(inc.reportedAt).toISOString().split('T')[0] : '', + inc.closedAt ? new Date(inc.closedAt).toISOString().split('T')[0] : '', + inc.injuryInvolved ? 'Yes' : 'No', + inc.medicalStatus ?? '', + String(inc.lostDays ?? ''), + ]) const csv = rowsToCsv(headers, csvRows) const safeRole = role.replace(/[^a-z0-9]/gi, '') const filename = `incidents-${safeRole}-${new Date().toISOString().split('T')[0]}.csv` - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: session.sub, - p_action: 'export_csv', - p_new_value: { role, row_count: rows.length } as never, - }) + await withUser(session.sub, async tx => + writeAuditLog(tx, 'incidents', session.sub, 'export_csv', { role, row_count: rows.length }) + ) return new NextResponse(csv, { status: 200, diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts index 30c823f..4ca8b21 100644 --- a/app/api/dashboard/stats/route.ts +++ b/app/api/dashboard/stats/route.ts @@ -1,8 +1,10 @@ export const dynamic = 'force-dynamic' import { 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 { incidents, sites } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' export async function GET() { const session = await getSession() @@ -10,28 +12,30 @@ export async function GET() { if (!['hse', 'admin', 'management'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - - const { data: incidents, error } = await supabase - .from('incidents') - .select('id, status, incident_type, sites (name)') + const rows = await withUser(session.sub, async tx => + tx.select({ + id: incidents.id, + status: incidents.status, + incidentType: incidents.incidentType, + siteName: sites.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) .limit(10000) + ) - if (error) return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 }) - - const rows = incidents ?? [] const total = rows.length const closed = rows.filter(r => r.status === 'closed').length const open = total - closed const by_type: Record = {} for (const r of rows) { - by_type[r.incident_type] = (by_type[r.incident_type] ?? 0) + 1 + by_type[r.incidentType] = (by_type[r.incidentType] ?? 0) + 1 } const siteMap: Record = {} for (const r of rows) { - const name = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown' + const name = r.siteName ?? 'Unknown' siteMap[name] = (siteMap[name] ?? 0) + 1 } const by_site = Object.entries(siteMap).map(([name, count]) => ({ name, count })) diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index 109f72f..4cd45c0 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -1,56 +1,84 @@ 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 { notificationsLog } from '@/lib/db/schema' +import { eq, and, isNull, inArray, desc, sql } from 'drizzle-orm' export async function GET() { const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const supabase = await createClient() - - const { data: notifications, error } = await supabase - .from('notifications_log') - .select('id, title, link, incident_id, capa_id, sent_at, read_at') - .eq('channel', 'in_app') - .eq('recipient_user_id', session.sub) - .order('sent_at', { ascending: false }) + const notifications = await withUser(session.sub, async tx => + tx.select({ + id: notificationsLog.id, + title: notificationsLog.title, + link: notificationsLog.link, + incidentId: notificationsLog.incidentId, + capaId: notificationsLog.capaId, + sentAt: notificationsLog.sentAt, + readAt: notificationsLog.readAt, + }) + .from(notificationsLog) + .where(and( + eq(notificationsLog.channel, 'in_app'), + eq(notificationsLog.recipientUserId, session.sub), + )) + .orderBy(desc(notificationsLog.sentAt)) .limit(20) + ) - if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 }) + const [unreadRow] = await withUser(session.sub, async tx => + tx.select({ count: sql`count(*)` }) + .from(notificationsLog) + .where(and( + eq(notificationsLog.channel, 'in_app'), + eq(notificationsLog.recipientUserId, session.sub), + isNull(notificationsLog.readAt), + )) + ) + const unread = Number(unreadRow?.count ?? 0) - const { count } = await supabase - .from('notifications_log') - .select('id', { count: 'exact', head: true }) - .eq('channel', 'in_app') - .eq('recipient_user_id', session.sub) - .is('read_at', null) - - return NextResponse.json({ notifications: notifications ?? [], unread: count ?? 0 }) + return NextResponse.json({ + notifications: notifications.map(n => ({ + id: n.id, + title: n.title, + link: n.link, + incident_id: n.incidentId, + capa_id: n.capaId, + sent_at: n.sentAt, + read_at: n.readAt, + })), + unread, + }) } export async function POST(request: NextRequest) { const session = await getSession() if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const supabase = await createClient() - const body: { ids?: string[]; all?: boolean } = await request.json().catch(() => ({})) - let query = supabase - .from('notifications_log') - .update({ read_at: new Date().toISOString() }) - .eq('recipient_user_id', session.sub) - .is('read_at', null) - if (!body.all) { if (!Array.isArray(body.ids) || body.ids.length === 0) return NextResponse.json({ error: 'ids required unless all:true' }, { status: 422 }) - query = query.in('id', body.ids) } - const { error } = await query - if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) + const baseWhere = and( + eq(notificationsLog.recipientUserId, session.sub), + isNull(notificationsLog.readAt), + ) + + const whereClause = body.all + ? baseWhere + : and(baseWhere, inArray(notificationsLog.id, body.ids!)) + + await withUser(session.sub, async tx => + tx.update(notificationsLog) + .set({ readAt: new Date() }) + .where(whereClause) + ) + return NextResponse.json({ ok: true }) } diff --git a/app/api/reports/jkkp8/route.ts b/app/api/reports/jkkp8/route.ts index ca9716b..2d1ae50 100644 --- a/app/api/reports/jkkp8/route.ts +++ b/app/api/reports/jkkp8/route.ts @@ -1,9 +1,12 @@ 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 { incidents, sites, zones, users, doshReports } from '@/lib/db/schema' import { buildJkkp8Rows, jkkp8Csv, type Jkkp8Incident } from '@/lib/reports/jkkp8' +import { aliasedTable, eq, and, gte, lt, asc, inArray } from 'drizzle-orm' export async function GET(request: NextRequest) { const session = await getSession() @@ -11,37 +14,90 @@ export async function GET(request: NextRequest) { if (!['hse', 'admin'].includes(session.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - const yearParam = request.nextUrl.searchParams.get('year') const year = Number.parseInt(yearParam ?? '', 10) || new Date().getFullYear() - const { data: incidents, error } = await supabase - .from('incidents') - .select(` - reference_no, incident_type, description, reported_at, - medical_status, lost_days, - is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease, - sites (name), - zones (name), - reporter:users!reported_by (name), - dosh_reports (form_type, status, submitted_at) - `) - .gte('reported_at', `${year}-01-01T00:00:00Z`) - .lt('reported_at', `${year + 1}-01-01T00:00:00Z`) - .order('reported_at', { ascending: true }) + const reporterAlias = aliasedTable(users, 'reporter') - if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 }) + const incidentRows = await withUser(session.sub, async tx => + tx.select({ + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + description: incidents.description, + reportedAt: incidents.reportedAt, + medicalStatus: incidents.medicalStatus, + lostDays: incidents.lostDays, + isFatality: incidents.isFatality, + isSeriousBodilyInjury: incidents.isSeriousBodilyInjury, + isDangerousOccurrence: incidents.isDangerousOccurrence, + isOccupationalDisease: incidents.isOccupationalDisease, + incidentId: incidents.id, + siteName: sites.name, + zoneName: zones.name, + reporterName: reporterAlias.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id)) + .where(and( + gte(incidents.reportedAt, new Date(`${year}-01-01T00:00:00Z`)), + lt(incidents.reportedAt, new Date(`${year + 1}-01-01T00:00:00Z`)), + )) + .orderBy(asc(incidents.reportedAt)) + ) - const rows = buildJkkp8Rows((incidents ?? []) as unknown as Jkkp8Incident[]) + // Fetch dosh_reports for all incidents in a single query + const incidentIds = incidentRows.map(r => r.incidentId) + const doshRows = incidentIds.length > 0 + ? await withUser(session.sub, async tx => + tx.select({ + incidentId: doshReports.incidentId, + formType: doshReports.formType, + status: doshReports.status, + submittedAt: doshReports.submittedAt, + }) + .from(doshReports) + .where(inArray(doshReports.incidentId, incidentIds)) + ) + : [] + + // Group dosh rows by incidentId + const doshByIncident = new Map>() + for (const d of doshRows) { + const existing = doshByIncident.get(d.incidentId) ?? [] + existing.push({ + form_type: d.formType, + status: d.status, + submitted_at: d.submittedAt ? d.submittedAt.toISOString() : null, + }) + doshByIncident.set(d.incidentId, existing) + } + + // Map Drizzle camelCase rows to Jkkp8Incident snake_case shape + const jkkp8Incidents: Jkkp8Incident[] = incidentRows.map(r => ({ + reference_no: r.referenceNo, + incident_type: r.incidentType, + description: r.description ?? '', + reported_at: r.reportedAt ? r.reportedAt.toISOString() : '', + medical_status: r.medicalStatus, + lost_days: r.lostDays, + is_fatality: r.isFatality ?? false, + is_serious_bodily_injury: r.isSeriousBodilyInjury ?? false, + is_dangerous_occurrence: r.isDangerousOccurrence ?? false, + is_occupational_disease: r.isOccupationalDisease ?? false, + sites: r.siteName ? { name: r.siteName } : null, + zones: r.zoneName ? { name: r.zoneName } : null, + reporter: r.reporterName ? { name: r.reporterName } : null, + dosh_reports: doshByIncident.get(r.incidentId) ?? [], + })) + + const rows = buildJkkp8Rows(jkkp8Incidents) const csv = jkkp8Csv(rows) - await supabase.rpc('write_audit_log', { - p_table_name: 'incidents', - p_record_id: session.sub, - p_action: 'jkkp8_register_export', - p_new_value: { year, row_count: rows.length }, - }) + await withUser(session.sub, async tx => + writeAuditLog(tx, 'incidents', session.sub, 'jkkp8_register_export', { year, row_count: rows.length }) + ) return new NextResponse(csv, { status: 200, diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 1a2b751..e26a421 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,8 +1,10 @@ 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 { asAdmin, withUser } from '@/lib/db/with-user' +import { writeAuditLog } from '@/lib/db/audit' +import { appSettings } from '@/lib/db/schema' const ALLOWED_KEYS = [ 'DEEPSEEK_API_KEY', @@ -16,14 +18,15 @@ export async function GET() { const session = await getSession() if (!session || session.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - - const { data } = await supabase.from('app_settings').select('key, value, updated_at') - const masked = (data ?? []).map(row => ({ + const data = await asAdmin(db => + db.select({ key: appSettings.key, value: appSettings.value, updatedAt: appSettings.updatedAt }) + .from(appSettings) + ) + const masked = data.map(row => ({ key: row.key, set: Boolean(row.value), masked_value: row.value ? `${row.value.slice(0, 8)}${'•'.repeat(12)}` : '', - updated_at: row.updated_at, + updated_at: row.updatedAt, })) return NextResponse.json(masked) } @@ -32,8 +35,6 @@ export async function POST(request: NextRequest) { const session = await getSession() if (!session || session.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const supabase = await createClient() - let body: { key?: string; value?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) @@ -46,20 +47,21 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'value must be a non-empty string' }, { status: 422 }) } - const { error } = await supabase.from('app_settings').upsert({ - key: body.key, - value: body.value, - updated_at: new Date().toISOString(), - updated_by: session.sub, - }) - if (error) return NextResponse.json({ error: 'Save failed' }, { status: 500 }) + 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 supabase.rpc('write_audit_log', { - p_table_name: 'app_settings', - p_record_id: session.sub, - p_action: 'UPDATE', - p_new_value: { key: body.key, set: Boolean(body.value) } as never, - }) + 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 }) } diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..9be5138 --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { asAdmin } from '@/lib/db/with-user' +import { users } from '@/lib/db/schema' +import { eq, asc } from 'drizzle-orm' + +export const dynamic = 'force-dynamic' + +export async function GET() { + try { + const result = await asAdmin(db => + db.select({ id: users.id, name: users.name, department: users.department, active: users.active }) + .from(users) + .where(eq(users.active, true)) + .orderBy(asc(users.name)) + ) + return NextResponse.json(result) + } catch { + return NextResponse.json([]) + } +}