From 690485f74e4bb0d8cf40c95f3e572d777089055b Mon Sep 17 00:00:00 2001 From: weeihan Date: Mon, 13 Jul 2026 16:05:36 +0800 Subject: [PATCH] feat: delete + deactivate for sites, zones, and trucks; harden incident API error handling - zones: add active column (migration 20260713000005) - sites/zones API: PATCH (toggle active) + DELETE (blocked when incidents reference it) - trucks API: PATCH + DELETE with same pattern - admin page: select active for sites + zones - site-zone-manager + truck-manager: deactivate toggle + delete button per row with busyId - incidents API: reject reports on deactivated zone/site; wrap handler in top-level try-catch so unhandled errors return JSON (not HTML) - report-form: parse JSON separately so HTTP status code surfaces instead of generic "Something went wrong" Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ --- app/(protected)/admin/page.tsx | 2 +- app/api/admin/sites/route.ts | 65 +++++++++++ app/api/admin/trucks/route.ts | 60 +++++++++++ app/api/incidents/route.ts | 14 ++- components/admin/site-zone-manager.tsx | 102 +++++++++++++++--- components/admin/truck-manager.tsx | 73 ++++++++++--- components/incidents/report-form.tsx | 17 ++- .../20260713000005_zones_active.sql | 4 + 8 files changed, 307 insertions(+), 30 deletions(-) create mode 100644 supabase/migrations/20260713000005_zones_active.sql diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx index 1c3c7ef..93930e1 100644 --- a/app/(protected)/admin/page.tsx +++ b/app/(protected)/admin/page.tsx @@ -23,7 +23,7 @@ export default async function AdminHome() { .order('created_at', { ascending: false }), supabase .from('sites') - .select('id, name, address, zones (id, name, qr_code_token)') + .select('id, name, address, active, zones (id, name, qr_code_token, active)') .order('name'), supabase .from('trucks') diff --git a/app/api/admin/sites/route.ts b/app/api/admin/sites/route.ts index a2d8a1d..2018c53 100644 --- a/app/api/admin/sites/route.ts +++ b/app/api/admin/sites/route.ts @@ -45,3 +45,68 @@ export async function POST(request: NextRequest) { }) return NextResponse.json({ id: site.id }, { status: 201 }) } + +export async function PATCH(request: NextRequest) { + const { supabase, user } = await requireAdmin() + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const body: { kind?: 'site' | 'zone'; id?: string; active?: boolean } = + await request.json().catch(() => ({})) + if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 }) + const table = body.kind === 'zone' ? 'zones' : 'sites' + + const { data: before } = await supabase.from(table).select('*').eq('id', body.id).single() + const { error } = await supabase.from(table).update({ active: body.active }).eq('id', body.id) + if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) + + await supabase.rpc('write_audit_log', { + p_table_name: table, + p_record_id: body.id, + p_action: 'admin_update', + p_old_value: before, + p_new_value: { active: body.active }, + }) + return NextResponse.json({ ok: true }) +} + +export async function DELETE(request: NextRequest) { + const { supabase, user } = await requireAdmin() + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const body: { kind?: 'site' | 'zone'; id?: string } = await request.json().catch(() => ({})) + if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 }) + const table = body.kind === 'zone' ? 'zones' : 'sites' + const col = body.kind === 'zone' ? 'zone_id' : 'site_id' + + // Count referencing incidents for a clear, actionable error message + const { count } = await supabase + .from('incidents') + .select('id', { count: 'exact', head: true }) + .eq(col, body.id) + if ((count ?? 0) > 0) + return NextResponse.json( + { error: `Cannot delete — ${count} incident(s) reference this. Deactivate it instead.` }, + { status: 409 }, + ) + + const { data: before } = await supabase.from(table).select('*').eq('id', body.id).single() + const { error } = await supabase.from(table).delete().eq('id', body.id) + if (error) + return NextResponse.json( + { + error: + error.code === '23503' + ? 'Cannot delete — this is still referenced elsewhere. Deactivate it instead.' + : 'Delete failed', + }, + { status: error.code === '23503' ? 409 : 500 }, + ) + + await supabase.rpc('write_audit_log', { + p_table_name: table, + p_record_id: body.id, + p_action: 'DELETE', + p_old_value: before, + }) + return NextResponse.json({ ok: true }) +} diff --git a/app/api/admin/trucks/route.ts b/app/api/admin/trucks/route.ts index d954369..2a9d0ac 100644 --- a/app/api/admin/trucks/route.ts +++ b/app/api/admin/trucks/route.ts @@ -32,3 +32,63 @@ export async function POST(request: NextRequest) { return NextResponse.json({ id: truck.id }, { status: 201 }) } + +export async function PATCH(request: NextRequest) { + const { supabase, user } = await requireAdmin() + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const body: { id?: string; active?: boolean } = await request.json().catch(() => ({})) + if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 }) + + const { data: before } = await supabase.from('trucks').select('*').eq('id', body.id).single() + const { error } = await supabase.from('trucks').update({ active: body.active }).eq('id', body.id) + if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 }) + + await supabase.rpc('write_audit_log', { + p_table_name: 'trucks', + p_record_id: body.id, + p_action: 'admin_update', + p_old_value: before, + p_new_value: { active: body.active }, + }) + return NextResponse.json({ ok: true }) +} + +export async function DELETE(request: NextRequest) { + const { supabase, user } = await requireAdmin() + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const body: { id?: string } = await request.json().catch(() => ({})) + if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 }) + + const { count } = await supabase + .from('incidents') + .select('id', { count: 'exact', head: true }) + .eq('truck_id', body.id) + if ((count ?? 0) > 0) + return NextResponse.json( + { error: `Cannot delete — ${count} incident(s) reference this truck. Deactivate it instead.` }, + { status: 409 }, + ) + + const { data: before } = await supabase.from('trucks').select('*').eq('id', body.id).single() + const { error } = await supabase.from('trucks').delete().eq('id', body.id) + if (error) + return NextResponse.json( + { + error: + error.code === '23503' + ? 'Cannot delete — this truck is still referenced elsewhere. Deactivate it instead.' + : 'Delete failed', + }, + { status: error.code === '23503' ? 409 : 500 }, + ) + + await supabase.rpc('write_audit_log', { + p_table_name: 'trucks', + p_record_id: body.id, + p_action: 'DELETE', + p_old_value: before, + }) + return NextResponse.json({ ok: true }) +} diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index ee2c1d4..aae1d82 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -10,6 +10,15 @@ import { getApiKey } from '@/lib/settings' export const dynamic = 'force-dynamic' export async function POST(request: Request) { + try { + return await handlePost(request) + } catch (err) { + console.error('Unhandled error in POST /api/incidents:', err) + return NextResponse.json({ error: 'Internal server error', details: [String(err)] }, { status: 500 }) + } +} + +async function handlePost(request: Request) { const supabase = await createClient() const { data, error: authError } = await supabase.auth.getUser() if (authError || !data?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -80,13 +89,16 @@ export async function POST(request: Request) { const { data: zone, error: zoneError } = await supabase .from('zones') - .select('id, site_id') + .select('id, site_id, active, sites(active)') .eq('qr_code_token', input.zone_token) .single() if (zoneError || !zone) { return NextResponse.json({ error: 'Zone not found' }, { status: 404 }) } + if (zone.active === false || (zone.sites as { active?: boolean } | null)?.active === false) { + return NextResponse.json({ error: 'This location is no longer active for reporting.' }, { status: 409 }) + } const { data: incident, error: incidentError } = await supabase .from('incidents') diff --git a/components/admin/site-zone-manager.tsx b/components/admin/site-zone-manager.tsx index 01f0623..35aaffb 100644 --- a/components/admin/site-zone-manager.tsx +++ b/components/admin/site-zone-manager.tsx @@ -7,7 +7,8 @@ export type SiteWithZones = { id: string name: string address: string | null - zones: Array<{ id: string; name: string; qr_code_token: string }> + active: boolean + zones: Array<{ id: string; name: string; qr_code_token: string; active: boolean }> } interface Props { @@ -20,6 +21,7 @@ export function SiteZoneManager({ sites }: Props) { const [zoneName, setZoneName] = useState('') const [zoneSiteId, setZoneSiteId] = useState('') const [busy, setBusy] = useState(false) + const [busyId, setBusyId] = useState(null) const [error, setError] = useState(null) const post = async (payload: Record) => { @@ -40,6 +42,25 @@ export function SiteZoneManager({ sites }: Props) { return true } + const send = async (method: 'PATCH' | 'DELETE', payload: Record) => { + const id = payload.id as string + setBusyId(id) + setError(null) + const res = await fetch('/ims/api/admin/sites', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + setBusyId(null) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setError(data.error ?? 'Action failed') + return false + } + router.refresh() + return true + } + return (

Sites & Zones

@@ -88,21 +109,78 @@ export function SiteZoneManager({ sites }: Props) {
{sites.map(site => ( -
-

{site.name}

- {site.address &&

{site.address}

} +
+
+
+

{site.name}

+ {site.address &&

{site.address}

} +
+
+ + +
+
+ {site.zones.length > 0 ? (
    {site.zones.map(z => ( -
  • +
  • {z.name} - - Report link / QR target - +
    + + Report link / QR target + + + +
  • ))}
diff --git a/components/admin/truck-manager.tsx b/components/admin/truck-manager.tsx index d6ecd8c..900f86b 100644 --- a/components/admin/truck-manager.tsx +++ b/components/admin/truck-manager.tsx @@ -19,6 +19,7 @@ export function TruckManager({ trucks }: Props) { const [truckNo, setTruckNo] = useState('') const [carrier, setCarrier] = useState('') const [submitting, setSubmitting] = useState(false) + const [busyId, setBusyId] = useState(null) const [error, setError] = useState(null) async function handleAdd() { @@ -47,6 +48,31 @@ export function TruckManager({ trucks }: Props) { } } + const send = async (method: 'PATCH' | 'DELETE', payload: Record) => { + const id = payload.id as string + setBusyId(id) + setError(null) + try { + const res = await fetch('/ims/api/admin/trucks', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setError(data.error ?? 'Action failed') + return false + } + router.refresh() + return true + } catch { + setError('Network error') + return false + } finally { + setBusyId(null) + } + } + return (
@@ -89,25 +115,46 @@ export function TruckManager({ trucks }: Props) { ) : (
    {trucks.map(tk => ( -
  • +
  • {tk.truck_no} {tk.carrier && ( {tk.carrier} )} - {!tk.active && ( - - Inactive - - )}
    - - Report link / QR target - +
    + + Report link / QR target + + + +
  • ))}
diff --git a/components/incidents/report-form.tsx b/components/incidents/report-form.tsx index 9a33e11..b20e7f5 100644 --- a/components/incidents/report-form.tsx +++ b/components/incidents/report-form.tsx @@ -177,15 +177,26 @@ export function ReportForm({ zoneToken, trucks, initialTruckId }: Props) { files.forEach(f => fd.append('files', f)) const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd }) - const data = await res.json() + + // Parse JSON separately so a non-JSON response (HTML error page) surfaces the status code + let data: Record = {} + try { + data = await res.json() + } catch { + console.error('Non-JSON response from /api/incidents', res.status, res.statusText) + setError(`${t.errorGeneric} (HTTP ${res.status})`) + return + } if (!res.ok) { - setError(data.details ? data.details.join('. ') : data.error) + const msg = Array.isArray(data.details) ? (data.details as string[]).join('. ') : (data.error as string) + setError(msg ?? t.errorGeneric) return } router.push(`/report/success?ref=${data.reference_no}`) - } catch { + } catch (err) { + console.error('Submit error:', err) setError(t.errorGeneric) } finally { setSubmitting(false) diff --git a/supabase/migrations/20260713000005_zones_active.sql b/supabase/migrations/20260713000005_zones_active.sql new file mode 100644 index 0000000..7a24fe7 --- /dev/null +++ b/supabase/migrations/20260713000005_zones_active.sql @@ -0,0 +1,4 @@ +-- Add soft-delete support to zones (sites and trucks already have this column). +-- Leave zones_read as USING (true) so deactivated zones still render on historical incidents. +-- Deactivation is enforced for new reports in the create-incident API, not via RLS. +ALTER TABLE zones ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;