diff --git a/app/api/admin/sites/route.ts b/app/api/admin/sites/route.ts index 1dcd3a0..a2d8a1d 100644 --- a/app/api/admin/sites/route.ts +++ b/app/api/admin/sites/route.ts @@ -1,17 +1,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' - -async function requireAdmin() { - const supabase = await createClient() - const { data: { user }, error } = await supabase.auth.getUser() - if (error || !user) return { supabase, user: null } - const { data: profile } = await supabase - .from('users').select('role').eq('id', user.id).single() - if (!profile || profile.role !== 'admin') return { supabase, user: null } - return { supabase, user } -} +import { requireAdmin } from '@/lib/auth/require-admin' export async function POST(request: NextRequest) { const { supabase, user } = await requireAdmin() diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 9b3b368..d45e278 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,19 +1,9 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { createAdminClient } from '@/lib/supabase/admin' import { isValidRole } from '@/lib/auth/roles' - -async function requireAdmin() { - const supabase = await createClient() - const { data: { user }, error } = await supabase.auth.getUser() - if (error || !user) return { supabase, user: null } - const { data: profile } = await supabase - .from('users').select('role').eq('id', user.id).single() - if (!profile || profile.role !== 'admin') return { supabase, user: null } - return { supabase, user } -} +import { requireAdmin } from '@/lib/auth/require-admin' export async function GET() { const { supabase, user } = await requireAdmin() @@ -108,6 +98,7 @@ export async function PATCH(request: NextRequest) { const { data: before } = await supabase .from('users').select('role, site_id, active, department').eq('id', body.id).single() + if (!before) return NextResponse.json({ error: 'User not found' }, { status: 404 }) const { error } = await supabase.from('users').update(update).eq('id', body.id) if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) diff --git a/app/api/dashboard/export/route.ts b/app/api/dashboard/export/route.ts index b007602..5558420 100644 --- a/app/api/dashboard/export/route.ts +++ b/app/api/dashboard/export/route.ts @@ -2,21 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' - -function escapeCsv(value: string | number | null | undefined): string { - if (value === null || value === undefined) return '' - const str = String(value) - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - -function rowsToCsv(headers: string[], rows: string[][]): string { - const lines = [headers.map(escapeCsv).join(',')] - for (const row of rows) lines.push(row.map(escapeCsv).join(',')) - return lines.join('\r\n') -} +import { rowsToCsv } from '@/lib/csv' export async function GET(request: NextRequest) { const supabase = await createClient() diff --git a/app/api/incidents/[id]/addenda/route.ts b/app/api/incidents/[id]/addenda/route.ts index 78a8210..ab9f369 100644 --- a/app/api/incidents/[id]/addenda/route.ts +++ b/app/api/incidents/[id]/addenda/route.ts @@ -41,6 +41,8 @@ export async function POST( const body: { body?: string } = await request.json().catch(() => ({})) const text = (body.body ?? '').trim() if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 }) + if (text.length > 5000) + return NextResponse.json({ error: 'body must be 5000 characters or fewer' }, { status: 422 }) const { data: addendum, error } = await supabase .from('incident_addenda') @@ -54,7 +56,7 @@ export async function POST( p_table_name: 'incident_addenda', p_record_id: addendum.id, p_action: 'INSERT', - p_new_value: { incident_id: id, author: user.id }, + p_new_value: { incident_id: id, author: user.id, body: text }, }) return NextResponse.json({ id: addendum.id }, { status: 201 }) diff --git a/app/api/incidents/[id]/similar/route.ts b/app/api/incidents/[id]/similar/route.ts index 666b22c..af34058 100644 --- a/app/api/incidents/[id]/similar/route.ts +++ b/app/api/incidents/[id]/similar/route.ts @@ -22,12 +22,12 @@ export async function GET( const { data: incident } = await supabase .from('incidents') - .select('id, description, embedding') + .select('id, description, embedding, status') .eq('id', id) .single() if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - const inc = incident as { id: string; description: string; embedding: string | null } + const inc = incident as { id: string; description: string; embedding: string | null; status: string } let embeddingVec: number[] try { @@ -35,9 +35,14 @@ export async function GET( embeddingVec = JSON.parse(inc.embedding) as number[] } else { embeddingVec = await embedText(inc.description, voyageKey) - await supabase.from('incidents').update({ - embedding: `[${embeddingVec.join(',')}]` as unknown as string, - }).eq('id', id) + // Closed incidents are locked at the DB level — the trigger would reject + // this backfill. The vector still serves the similarity query below. + if (inc.status !== 'closed') { + const { error: persistError } = await supabase.from('incidents').update({ + embedding: `[${embeddingVec.join(',')}]` as unknown as string, + }).eq('id', id) + if (persistError) console.error('embedding backfill error:', persistError) + } } } catch { return NextResponse.json({ error: 'Embedding service unavailable' }, { status: 503 }) diff --git a/lib/auth/require-admin.ts b/lib/auth/require-admin.ts new file mode 100644 index 0000000..bb2612e --- /dev/null +++ b/lib/auth/require-admin.ts @@ -0,0 +1,18 @@ +import { createClient } from '@/lib/supabase/server' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { User } from '@supabase/supabase-js' + +// Shared guard for /api/admin/* routes: resolves the session and requires +// the admin role. Returns user: null when the caller must respond 403. +export async function requireAdmin(): Promise<{ + supabase: SupabaseClient + user: User | null +}> { + const supabase = await createClient() + const { data: { user }, error } = await supabase.auth.getUser() + if (error || !user) return { supabase, user: null } + const { data: profile } = await supabase + .from('users').select('role').eq('id', user.id).single() + if (!profile || profile.role !== 'admin') return { supabase, user: null } + return { supabase, user } +} diff --git a/lib/csv.ts b/lib/csv.ts new file mode 100644 index 0000000..4a6a579 --- /dev/null +++ b/lib/csv.ts @@ -0,0 +1,14 @@ +export function escapeCsv(value: string | number | null | undefined): string { + if (value === null || value === undefined) return '' + const str = String(value) + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export function rowsToCsv(headers: string[], rows: string[][]): string { + const lines = [headers.map(escapeCsv).join(',')] + for (const row of rows) lines.push(row.map(escapeCsv).join(',')) + return lines.join('\r\n') +} diff --git a/lib/reports/jkkp8.ts b/lib/reports/jkkp8.ts index c95712b..b452851 100644 --- a/lib/reports/jkkp8.ts +++ b/lib/reports/jkkp8.ts @@ -1,4 +1,5 @@ import { computeDoshObligation } from '@/lib/incidents/dosh' +import { escapeCsv } from '@/lib/csv' export interface Jkkp8Incident { reference_no: string | null @@ -73,15 +74,6 @@ export const JKKP8_HEADERS = [ 'Description', 'Medical Status', 'Lost Days', 'NADOPOD Obligation', 'DOSH Filing Status', ] -export function escapeCsv(value: string | number | null | undefined): string { - if (value === null || value === undefined) return '' - const str = String(value) - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - export function jkkp8Csv(rows: Jkkp8Row[]): string { const lines = [JKKP8_HEADERS.map(escapeCsv).join(',')] for (const r of rows) {