diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..f18272b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": "next/core-web-vitals" +} + diff --git a/.gitignore b/.gitignore index f3675f1..cfb4871 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ next-env.d.ts # claude .claude .mcp.json + +/archive/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3e9353d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed - 2025-11-22 + +#### Route Consolidatie: `/clients/` → `/patients/` + +**Migratie:** Consolideer beide routes naar één FHIR-compliant `/patients/` route met Custom API voor Intakes. + +**Wijzigingen:** +- **Routes:** Alle `/epd/clients/*` routes zijn gemigreerd naar `/epd/patients/*` + - `/epd/clients` → `/epd/patients` + - `/epd/clients/[id]` → `/epd/patients/[id]` + - `/epd/clients/[id]/intakes` → `/epd/patients/[id]/intakes` + - `/epd/clients/[id]/intakes/[intakeId]` → `/epd/patients/[id]/intakes/[intakeId]` + +- **Backward Compatibility:** + - Catch-all redirect route toegevoegd: `/epd/clients/[...path]/route.ts` + - Alle oude `/clients/` URLs worden automatisch doorgestuurd naar `/patients/` + - Query parameters worden behouden tijdens redirects + +- **Intake Module:** + - Volledige Intake module gemigreerd van `/clients/` naar `/patients/` + - Custom API geïmplementeerd: `/api/intakes` + - Type definitions toegevoegd: `lib/types/intake.ts` + - Server actions refactored naar API-based: `app/epd/patients/[id]/intakes/actions.ts` + +- **Code Archive:** + - Oude `/clients/` code gearchiveerd naar `app/epd/_archive/clients_backup_20251122/` + - Alleen redirect routes blijven actief voor backward compatibility + +**Breaking Changes:** +- Nieuwe code moet `/epd/patients/` routes gebruiken +- Oude `/epd/clients/` routes werken nog via redirects, maar worden deprecated + +**Migration Guide:** +Zie `docs/migratie-clients-naar-patients.md` voor volledige migratie details. + +**Files Changed:** +- `app/epd/patients/[id]/intakes/` - Nieuwe Intake module +- `app/epd/clients/[...path]/route.ts` - Catch-all redirect +- `app/epd/clients/page.tsx` - Root redirect +- `lib/types/intake.ts` - Type definitions +- `app/api/intakes/` - Custom API routes +- `docs/specs/UI/bouwplan-mini-epd-v1.0.md` - Route references updated + diff --git a/app/api/deepgram/transcribe/route.ts b/app/api/deepgram/transcribe/route.ts new file mode 100644 index 0000000..ac375fe --- /dev/null +++ b/app/api/deepgram/transcribe/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const DEEPGRAM_URL = 'https://api.deepgram.com/v1/listen'; + +export async function POST(request: NextRequest) { + try { + const apiKey = process.env.DEEPGRAM_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: 'DEEPGRAM_API_KEY ontbreekt' }, + { status: 500 } + ); + } + + const formData = await request.formData(); + const file = formData.get('file'); + + if (!(file instanceof File)) { + return NextResponse.json({ error: 'Bestand ontbreekt' }, { status: 400 }); + } + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const response = await fetch(DEEPGRAM_URL, { + method: 'POST', + headers: { + Authorization: `Token ${apiKey}`, + 'Content-Type': file.type || 'audio/webm', + }, + body: buffer, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Deepgram error', response.status, errorText); + return NextResponse.json( + { error: 'Deepgram transcribe mislukt', details: errorText }, + { status: 502 } + ); + } + + const data = await response.json(); + const transcript = + data.results?.channels?.[0]?.alternatives?.[0]?.transcript?.trim() || ''; + + return NextResponse.json({ transcript }); + } catch (error) { + console.error('Unexpected error in Deepgram route', error); + return NextResponse.json({ error: 'Onverwachte fout' }, { status: 500 }); + } +} diff --git a/app/api/intakes/[intakeId]/route.ts b/app/api/intakes/[intakeId]/route.ts new file mode 100644 index 0000000..ca9e7f7 --- /dev/null +++ b/app/api/intakes/[intakeId]/route.ts @@ -0,0 +1,203 @@ +/** + * Intake Detail API Routes + * + * GET /api/intakes/{intakeId} - Get specific intake + * PUT /api/intakes/{intakeId} - Update intake + * DELETE /api/intakes/{intakeId} - Delete intake + */ + +import { createClient } from '@/lib/auth/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +// Validation schema for updates +const UpdateIntakeSchema = z.object({ + title: z.string().min(1).optional(), + department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen']).optional(), + status: z.enum(['bezig', 'afgerond']).optional(), + start_date: z.string().optional(), + end_date: z.string().nullable().optional(), + psychologist_id: z.string().uuid().nullable().optional(), + notes: z.string().nullable().optional(), +}); + +/** + * GET /api/intakes/{intakeId} + * Get a specific intake by ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ intakeId: string }> } +) { + try { + const { intakeId } = await params; + + // Validate UUID format + if (!z.string().uuid().safeParse(intakeId).success) { + return NextResponse.json( + { error: 'intakeId moet een geldige UUID zijn' }, + { status: 400 } + ); + } + + const supabase = await createClient(); + + const { data, error } = await supabase + .from('intakes') + .select('*') + .eq('id', intakeId) + .single(); + + if (error) { + // Check if it's a not found error + if (error.code === 'PGRST116') { + return NextResponse.json( + { error: 'Intake niet gevonden' }, + { status: 404 } + ); + } + + console.error('Error fetching intake:', error); + return NextResponse.json( + { error: 'Fout bij ophalen intake', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error('Unexpected error in GET /api/intakes/[intakeId]:', error); + return NextResponse.json( + { error: 'Onverwachte serverfout' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/intakes/{intakeId} + * Update an existing intake + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ intakeId: string }> } +) { + try { + const { intakeId } = await params; + + // Validate UUID format + if (!z.string().uuid().safeParse(intakeId).success) { + return NextResponse.json( + { error: 'intakeId moet een geldige UUID zijn' }, + { status: 400 } + ); + } + + const body = await request.json(); + + // Validate input + const result = UpdateIntakeSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { + error: 'Validatiefout', + details: result.error.errors.map(e => ({ + field: e.path.join('.'), + message: e.message, + })), + }, + { status: 400 } + ); + } + + const supabase = await createClient(); + + // Update intake + const { data, error } = await supabase + .from('intakes') + .update(result.data) + .eq('id', intakeId) + .select() + .single(); + + if (error) { + // Check if it's a not found error + if (error.code === 'PGRST116') { + return NextResponse.json( + { error: 'Intake niet gevonden' }, + { status: 404 } + ); + } + + console.error('Error updating intake:', error); + return NextResponse.json( + { error: 'Fout bij bijwerken intake', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error('Unexpected error in PUT /api/intakes/[intakeId]:', error); + + // Handle JSON parse errors + if (error instanceof SyntaxError) { + return NextResponse.json( + { error: 'Ongeldige JSON in request body' }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Onverwachte serverfout' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/intakes/{intakeId} + * Delete an intake + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ intakeId: string }> } +) { + try { + const { intakeId } = await params; + + // Validate UUID format + if (!z.string().uuid().safeParse(intakeId).success) { + return NextResponse.json( + { error: 'intakeId moet een geldige UUID zijn' }, + { status: 400 } + ); + } + + const supabase = await createClient(); + + // Delete intake + const { error } = await supabase + .from('intakes') + .delete() + .eq('id', intakeId); + + if (error) { + console.error('Error deleting intake:', error); + return NextResponse.json( + { error: 'Fout bij verwijderen intake', details: error.message }, + { status: 500 } + ); + } + + // Return 204 No Content on successful deletion + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error('Unexpected error in DELETE /api/intakes/[intakeId]:', error); + return NextResponse.json( + { error: 'Onverwachte serverfout' }, + { status: 500 } + ); + } +} diff --git a/app/api/intakes/route.ts b/app/api/intakes/route.ts new file mode 100644 index 0000000..f601f0c --- /dev/null +++ b/app/api/intakes/route.ts @@ -0,0 +1,151 @@ +/** + * Intakes API Routes + * + * GET /api/intakes?patientId={id} - List all intakes for a patient + * POST /api/intakes - Create new intake + */ + +import { createClient } from '@/lib/auth/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +// Validation schemas +const CreateIntakeSchema = z.object({ + patient_id: z.string().uuid('Patient ID moet een geldige UUID zijn'), + title: z.string().min(1, 'Titel is verplicht'), + department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen'], { + errorMap: () => ({ message: 'Afdeling moet Volwassenen, Jeugd of Ouderen zijn' }), + }), + start_date: z.string().min(1, 'Startdatum is verplicht'), + psychologist_id: z.string().uuid().optional(), + notes: z.string().optional(), +}); + +/** + * GET /api/intakes?patientId={id} + * List all intakes for a specific patient + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const patientId = searchParams.get('patientId'); + + if (!patientId) { + return NextResponse.json( + { error: 'patientId query parameter is verplicht' }, + { status: 400 } + ); + } + + // Validate UUID format + if (!z.string().uuid().safeParse(patientId).success) { + return NextResponse.json( + { error: 'patientId moet een geldige UUID zijn' }, + { status: 400 } + ); + } + + const supabase = await createClient(); + + const { data, error } = await supabase + .from('intakes') + .select('*') + .eq('patient_id', patientId) + .order('start_date', { ascending: false }); + + if (error) { + console.error('Error fetching intakes:', error); + return NextResponse.json( + { error: 'Fout bij ophalen intakes', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ + intakes: data, + total: data.length, + }); + } catch (error) { + console.error('Unexpected error in GET /api/intakes:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + + return NextResponse.json( + { + error: 'Onverwachte serverfout', + details: errorMessage, + ...(process.env.NODE_ENV === 'development' && { stack: errorStack }), + }, + { status: 500 } + ); + } +} + +/** + * POST /api/intakes + * Create a new intake + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate input + const result = CreateIntakeSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { + error: 'Validatiefout', + details: result.error.errors.map(e => ({ + field: e.path.join('.'), + message: e.message, + })), + }, + { status: 400 } + ); + } + + const { patient_id, title, department, start_date, psychologist_id, notes } = result.data; + const supabase = await createClient(); + + // Create intake + const { data, error } = await supabase + .from('intakes') + .insert({ + patient_id, + title, + department, + start_date, + psychologist_id, + notes, + status: 'bezig', // Default status in database enum + }) + .select() + .single(); + + if (error) { + console.error('Error creating intake:', error); + return NextResponse.json( + { error: 'Fout bij aanmaken intake', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json(data, { status: 201 }); + } catch (error) { + console.error('Unexpected error in POST /api/intakes:', error); + + // Handle JSON parse errors + if (error instanceof SyntaxError) { + return NextResponse.json( + { error: 'Ongeldige JSON in request body' }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Onverwachte serverfout' }, + { status: 500 } + ); + } +} diff --git a/app/api/screenings/[screeningId]/activities/route.ts b/app/api/screenings/[screeningId]/activities/route.ts new file mode 100644 index 0000000..e0f36ac --- /dev/null +++ b/app/api/screenings/[screeningId]/activities/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; + +const createSchema = z.object({ + activity_text: z + .string() + .min(3, 'Beschrijf minimaal 3 tekens') + .max(2000, 'Maximaal 2000 tekens'), +}); + +async function getPractitionerForUser(supabase: Awaited>, userId?: string) { + if (!userId) return null; + + const { data } = await supabase + .from('practitioners') + .select('id, name_given, name_family') + .eq('user_id', userId) + .maybeSingle(); + + return data; +} + +function formatPractitionerName(practitioner: any) { + if (!practitioner) return null; + const given = (practitioner.name_given || []).join(' '); + const family = practitioner.name_family || ''; + return `${given} ${family}`.trim(); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ screeningId: string }> } +) { + try { + const { screeningId } = await params; + const supabase = await createClient(); + + const { data, error } = await supabase + .from('screening_activities') + .select('*') + .eq('screening_id', screeningId) + .order('created_at', { ascending: false }); + + if (error) { + console.error('Error fetching activities:', error); + return NextResponse.json( + { error: 'Fout bij ophalen activiteiten', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ activities: data || [] }); + } catch (error) { + console.error('Unexpected error in GET /api/screenings/[screeningId]/activities:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { error: 'Onverwachte serverfout', details: message }, + { status: 500 } + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ screeningId: string }> } +) { + try { + const body = await request.json(); + const parsed = createSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: 'Validatiefout', details: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const { screeningId } = await params; + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + const practitioner = await getPractitionerForUser(supabase, user.id); + const displayName = formatPractitionerName(practitioner) || user.user_metadata?.full_name || user.email || 'Onbekende gebruiker'; + + const { data, error } = await supabase + .from('screening_activities') + .insert({ + screening_id: screeningId, + activity_text: parsed.data.activity_text, + created_by: practitioner?.id || null, + created_by_name: displayName, + }) + .select('*') + .single(); + + if (error) { + console.error('Error creating activity:', error); + return NextResponse.json( + { error: 'Fout bij toevoegen activiteit', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ activity: data }, { status: 201 }); + } catch (error) { + console.error('Unexpected error in POST /api/screenings/[screeningId]/activities:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { error: 'Onverwachte serverfout', details: message }, + { status: 500 } + ); + } +} diff --git a/app/api/screenings/[screeningId]/documents/[documentId]/route.ts b/app/api/screenings/[screeningId]/documents/[documentId]/route.ts new file mode 100644 index 0000000..29f4681 --- /dev/null +++ b/app/api/screenings/[screeningId]/documents/[documentId]/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/auth/server'; +import { supabaseAdmin } from '@/lib/supabase/server'; + +const BUCKET = 'screening-documents'; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ screeningId: string; documentId: string }> } +) { + try { + const { documentId } = await params; + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + const { data: document, error } = await supabase + .from('screening_documents') + .select('*') + .eq('id', documentId) + .maybeSingle(); + + if (error) { + console.error('Fetch document error:', error); + return NextResponse.json( + { error: 'Document niet gevonden', details: error.message }, + { status: 500 } + ); + } + + if (!document) { + return NextResponse.json({ error: 'Document niet gevonden' }, { status: 404 }); + } + + const { error: deleteError } = await supabaseAdmin.storage + .from(BUCKET) + .remove([document.file_path]); + + if (deleteError) { + console.error('Storage delete error:', deleteError); + // Don't stop here; attempt DB delete even if storage failed + } + + const { error: dbError } = await supabase + .from('screening_documents') + .delete() + .eq('id', documentId); + + if (dbError) { + console.error('DB delete error:', dbError); + return NextResponse.json( + { error: 'Verwijderen mislukt', details: dbError.message }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Unexpected error in DELETE /documents/[documentId]:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/screenings/[screeningId]/documents/route.ts b/app/api/screenings/[screeningId]/documents/route.ts new file mode 100644 index 0000000..a73f91e --- /dev/null +++ b/app/api/screenings/[screeningId]/documents/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/auth/server'; +import { supabaseAdmin } from '@/lib/supabase/server'; + +const BUCKET = 'screening-documents'; + +async function ensureBucket() { + const { data, error } = await supabaseAdmin.storage.getBucket(BUCKET); + if (!data) { + const { error: createError } = await supabaseAdmin.storage.createBucket(BUCKET, { + public: true, + fileSizeLimit: 50 * 1024 * 1024, // 50 MB + }); + if (createError && !createError.message.includes('already exists')) { + throw createError; + } + } else if (error && !error.message.includes('not found')) { + throw error; + } +} + +async function getPractitionerByUser(supabase: Awaited>, userId?: string) { + if (!userId) return null; + const { data } = await supabase + .from('practitioners') + .select('id, name_given, name_family') + .eq('user_id', userId) + .maybeSingle(); + return data; +} + +function formatDisplayName(practitioner: any, fallback: string) { + if (practitioner) { + const given = (practitioner.name_given || []).join(' '); + const family = practitioner.name_family || ''; + return `${given} ${family}`.trim(); + } + return fallback; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ screeningId: string }> } +) { + try { + const { screeningId } = await params; + const supabase = await createClient(); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get('file'); + if (!(file instanceof File)) { + return NextResponse.json({ error: 'Bestand ontbreekt' }, { status: 400 }); + } + + const documentType = (formData.get('documentType') as string) || 'overig'; + + await ensureBucket(); + + const filePath = `${screeningId}/${Date.now()}-${file.name}`; + const arrayBuffer = await file.arrayBuffer(); + const { error: uploadError } = await supabaseAdmin.storage + .from(BUCKET) + .upload(filePath, Buffer.from(arrayBuffer), { + contentType: file.type, + cacheControl: '3600', + upsert: false, + }); + + if (uploadError) { + console.error('Upload error:', uploadError); + return NextResponse.json( + { error: 'Upload mislukt', details: uploadError.message }, + { status: 500 } + ); + } + + const practitioner = await getPractitionerByUser(supabase, user.id); + const displayName = formatDisplayName( + practitioner, + (user.user_metadata?.full_name as string) || user.email || 'Onbekend' + ); + + const { data, error } = await supabase + .from('screening_documents') + .insert({ + screening_id: screeningId, + file_name: file.name, + file_type: file.type, + file_size: file.size, + file_path: filePath, + document_type: documentType, + uploaded_by: practitioner?.id || null, + uploaded_by_name: displayName, + }) + .select('*') + .single(); + + if (error) { + console.error('DB insert error:', error); + return NextResponse.json( + { error: 'Opslaan mislukt', details: error.message }, + { status: 500 } + ); + } + + const { data: publicUrlData } = supabaseAdmin.storage.from(BUCKET).getPublicUrl(filePath); + + return NextResponse.json({ + document: { + ...data, + publicUrl: publicUrlData.publicUrl, + }, + }); + } catch (error) { + console.error('Unexpected error in POST /documents:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/screenings/[screeningId]/route.ts b/app/api/screenings/[screeningId]/route.ts new file mode 100644 index 0000000..946e089 --- /dev/null +++ b/app/api/screenings/[screeningId]/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; + +const updateSchema = z.object({ + request_for_help: z.string().optional(), + decision: z.enum(['geschikt', 'niet_geschikt']).optional(), + decision_notes: z.string().optional(), + decision_department: z.string().optional(), +}); + +function serializeScreening(screening: any) { + const activities = [...(screening.screening_activities || [])].sort((a, b) => { + const aTime = a.created_at ? new Date(a.created_at).getTime() : 0; + const bTime = b.created_at ? new Date(b.created_at).getTime() : 0; + return bTime - aTime; + }); + + const documents = [...(screening.screening_documents || [])].sort((a, b) => { + const aTime = a.uploaded_at ? new Date(a.uploaded_at).getTime() : 0; + const bTime = b.uploaded_at ? new Date(b.uploaded_at).getTime() : 0; + return bTime - aTime; + }); + + return { + ...screening, + screening_activities: activities, + screening_documents: documents, + }; +} + +async function getPractitionerForUser(supabase: Awaited>, userId?: string) { + if (!userId) return null; + + const { data } = await supabase + .from('practitioners') + .select('id, name_given, name_family') + .eq('user_id', userId) + .maybeSingle(); + + return data; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ screeningId: string }> } +) { + try { + const { screeningId } = await params; + const supabase = await createClient(); + + const { data, error } = await supabase + .from('screenings') + .select('*, screening_activities(*), screening_documents(*)') + .eq('id', screeningId) + .maybeSingle(); + + if (error) { + console.error('Error fetching screening:', error); + return NextResponse.json( + { error: 'Fout bij ophalen screening', details: error.message }, + { status: 500 } + ); + } + + if (!data) { + return NextResponse.json({ error: 'Screening niet gevonden' }, { status: 404 }); + } + + return NextResponse.json({ screening: serializeScreening(data) }); + } catch (error) { + console.error('Unexpected error in GET /api/screenings/[screeningId]:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { error: 'Onverwachte serverfout', details: message }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ screeningId: string }> } +) { + try { + const body = await request.json(); + const parsed = updateSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: 'Validatiefout', details: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const { screeningId } = await params; + const supabase = await createClient(); + + const updates: Record = {}; + + if (Object.prototype.hasOwnProperty.call(parsed.data, 'request_for_help')) { + updates.request_for_help = parsed.data.request_for_help || null; + } + + if (Object.prototype.hasOwnProperty.call(parsed.data, 'decision_notes')) { + updates.decision_notes = parsed.data.decision_notes || null; + } + + if (Object.prototype.hasOwnProperty.call(parsed.data, 'decision_department')) { + updates.decision_department = parsed.data.decision_department || null; + } + + if (Object.prototype.hasOwnProperty.call(parsed.data, 'decision')) { + const decision = parsed.data.decision; + + if (decision === 'geschikt' && !parsed.data.decision_department) { + return NextResponse.json( + { error: 'Afdeling is verplicht bij een positief besluit' }, + { status: 400 } + ); + } + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const practitioner = await getPractitionerForUser(supabase, user?.id); + + updates.decision = decision; + updates.decision_date = new Date().toISOString(); + updates.decision_by = practitioner?.id || null; + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: 'Geen velden om bij te werken ontvangen' }, + { status: 400 } + ); + } + + const { data, error } = await supabase + .from('screenings') + .update(updates) + .eq('id', screeningId) + .select('*, screening_activities(*), screening_documents(*)') + .single(); + + if (error) { + console.error('Error updating screening:', error); + return NextResponse.json( + { error: 'Fout bij bijwerken screening', details: error.message }, + { status: 500 } + ); + } + + if (parsed.data.decision) { + const newStatus = parsed.data.decision === 'geschikt' ? 'active' : 'cancelled'; + const { error: statusError } = await supabase + .from('patients') + .update({ status: newStatus }) + .eq('id', data.patient_id); + + if (statusError) { + console.error('Error updating patient status:', statusError); + } + } + + return NextResponse.json({ screening: serializeScreening(data) }); + } catch (error) { + console.error('Unexpected error in PUT /api/screenings/[screeningId]:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { error: 'Onverwachte serverfout', details: message }, + { status: 500 } + ); + } +} diff --git a/app/api/screenings/route.ts b/app/api/screenings/route.ts new file mode 100644 index 0000000..2325d80 --- /dev/null +++ b/app/api/screenings/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createClient } from '@/lib/auth/server'; + +const querySchema = z.object({ + patientId: z.string().uuid('patientId moet een geldige UUID zijn'), +}); + +const createSchema = z.object({ + patient_id: z.string().uuid('patient_id moet een geldige UUID zijn'), +}); + +function serializeScreening(screening: any) { + const activities = [...(screening.screening_activities || [])].sort((a, b) => { + const aTime = a.created_at ? new Date(a.created_at).getTime() : 0; + const bTime = b.created_at ? new Date(b.created_at).getTime() : 0; + return bTime - aTime; + }); + + const documents = [...(screening.screening_documents || [])].sort((a, b) => { + const aTime = a.uploaded_at ? new Date(a.uploaded_at).getTime() : 0; + const bTime = b.uploaded_at ? new Date(b.uploaded_at).getTime() : 0; + return bTime - aTime; + }); + + return { + ...screening, + screening_activities: activities, + screening_documents: documents, + }; +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const parsed = querySchema.safeParse({ patientId: searchParams.get('patientId') }); + + if (!parsed.success) { + return NextResponse.json( + { error: 'patientId query parameter is verplicht' }, + { status: 400 } + ); + } + + const supabase = await createClient(); + const { patientId } = parsed.data; + + const { data, error } = await supabase + .from('screenings') + .select('*, screening_activities(*), screening_documents(*)') + .eq('patient_id', patientId) + .maybeSingle(); + + if (error && error.code !== 'PGRST116') { + console.error('Error fetching screening:', error); + return NextResponse.json( + { error: 'Fout bij ophalen screening', details: error.message }, + { status: 500 } + ); + } + + if (!data) { + const { data: created, error: insertError } = await supabase + .from('screenings') + .insert({ patient_id: patientId }) + .select('*, screening_activities(*), screening_documents(*)') + .single(); + + if (insertError) { + console.error('Error creating screening:', insertError); + return NextResponse.json( + { error: 'Fout bij aanmaken screening', details: insertError.message }, + { status: 500 } + ); + } + + return NextResponse.json({ screening: serializeScreening(created) }); + } + + return NextResponse.json({ screening: serializeScreening(data) }); + } catch (error) { + console.error('Unexpected error in GET /api/screenings:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { error: 'Onverwachte serverfout', details: message }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const parsed = createSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: 'Validatiefout', details: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const supabase = await createClient(); + const { data, error } = await supabase + .from('screenings') + .insert({ patient_id: parsed.data.patient_id }) + .select('*, screening_activities(*), screening_documents(*)') + .single(); + + if (error) { + console.error('Error creating screening:', error); + return NextResponse.json( + { error: 'Fout bij aanmaken screening', details: error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ screening: serializeScreening(data) }, { status: 201 }); + } catch (error) { + console.error('Unexpected error in POST /api/screenings:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { error: 'Onverwachte serverfout', details: message }, + { status: 500 } + ); + } +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index ff32e9d..495e68e 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -15,7 +15,7 @@ export async function GET(request: NextRequest) { const token = requestUrl.searchParams.get('token') // Fallback for older templates const token_hash = requestUrl.searchParams.get('token_hash') // Supabase uses this for email links const type = requestUrl.searchParams.get('type') // recovery, signup, etc - const next = requestUrl.searchParams.get('next') ?? '/epd/clients' + const next = requestUrl.searchParams.get('next') ?? '/epd/patients' // Debug logging - ALWAYS log to diagnose redirect issues console.log('🔍 Auth Callback Debug:', { diff --git a/app/epd/_archive/clients_backup_20251122/[...path]/route.ts b/app/epd/_archive/clients_backup_20251122/[...path]/route.ts new file mode 100644 index 0000000..982968a --- /dev/null +++ b/app/epd/_archive/clients_backup_20251122/[...path]/route.ts @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; + +/** + * Catch-all Redirect Route for /epd/clients/ + * + * Redirects all /epd/clients/* routes to /epd/patients/* for backward compatibility + * This ensures that old bookmarks and links continue to work after migration. + * + * Preserves query parameters (e.g., ?tab=intake&search=test) + */ + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path } = await params; + const searchParams = request.nextUrl.searchParams; + + // Build new path with query parameters + const newPath = `/epd/patients/${path.join('/')}`; + const newUrl = new URL(newPath, request.url); + + // Preserve all query parameters + searchParams.forEach((value, key) => { + newUrl.searchParams.set(key, value); + }); + + redirect(newUrl.toString()); +} + diff --git a/app/epd/clients/[id]/components/client-tabs.tsx b/app/epd/_archive/clients_backup_20251122/[id]/components/client-tabs.tsx similarity index 100% rename from app/epd/clients/[id]/components/client-tabs.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/components/client-tabs.tsx diff --git a/app/epd/clients/[id]/components/intake-tab.tsx b/app/epd/_archive/clients_backup_20251122/[id]/components/intake-tab.tsx similarity index 100% rename from app/epd/clients/[id]/components/intake-tab.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/components/intake-tab.tsx diff --git a/app/epd/clients/[id]/components/plan-tab.tsx b/app/epd/_archive/clients_backup_20251122/[id]/components/plan-tab.tsx similarity index 100% rename from app/epd/clients/[id]/components/plan-tab.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/components/plan-tab.tsx diff --git a/app/epd/clients/[id]/components/profile-tab.tsx b/app/epd/_archive/clients_backup_20251122/[id]/components/profile-tab.tsx similarity index 100% rename from app/epd/clients/[id]/components/profile-tab.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/components/profile-tab.tsx diff --git a/app/epd/clients/[id]/dashboard/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/dashboard/page.tsx similarity index 100% rename from app/epd/clients/[id]/dashboard/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/dashboard/page.tsx diff --git a/app/epd/clients/[id]/diagnose/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/diagnose/page.tsx similarity index 100% rename from app/epd/clients/[id]/diagnose/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/diagnose/page.tsx diff --git a/app/epd/clients/[id]/edit/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/edit/page.tsx similarity index 100% rename from app/epd/clients/[id]/edit/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/edit/page.tsx diff --git a/app/epd/clients/[id]/intake/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intake/page.tsx similarity index 100% rename from app/epd/clients/[id]/intake/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intake/page.tsx diff --git a/app/epd/clients/[id]/intakes/[intakeId]/components/intake-header.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/components/intake-header.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/[intakeId]/components/intake-header.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/components/intake-header.tsx diff --git a/app/epd/clients/[id]/intakes/[intakeId]/components/intake-tabs.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/components/intake-tabs.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/[intakeId]/components/intake-tabs.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/components/intake-tabs.tsx diff --git a/app/epd/clients/[id]/intakes/[intakeId]/layout.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/layout.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/[intakeId]/layout.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/layout.tsx diff --git a/app/epd/clients/[id]/intakes/[intakeId]/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/page.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/[intakeId]/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/[intakeId]/page.tsx diff --git a/app/epd/clients/[id]/intakes/actions.ts b/app/epd/_archive/clients_backup_20251122/[id]/intakes/actions.ts similarity index 100% rename from app/epd/clients/[id]/intakes/actions.ts rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/actions.ts diff --git a/app/epd/clients/[id]/intakes/components/intake-card.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/components/intake-card.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/components/intake-card.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/components/intake-card.tsx diff --git a/app/epd/clients/[id]/intakes/components/intake-list.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/components/intake-list.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/components/intake-list.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/components/intake-list.tsx diff --git a/app/epd/clients/[id]/intakes/components/new-intake-form.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/components/new-intake-form.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/components/new-intake-form.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/components/new-intake-form.tsx diff --git a/app/epd/clients/[id]/intakes/new/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/intakes/new/page.tsx similarity index 100% rename from app/epd/clients/[id]/intakes/new/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/intakes/new/page.tsx diff --git a/app/epd/clients/[id]/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/page.tsx similarity index 100% rename from app/epd/clients/[id]/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/page.tsx diff --git a/app/epd/clients/[id]/plan/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/plan/page.tsx similarity index 100% rename from app/epd/clients/[id]/plan/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/plan/page.tsx diff --git a/app/epd/clients/[id]/reports/page.tsx b/app/epd/_archive/clients_backup_20251122/[id]/reports/page.tsx similarity index 100% rename from app/epd/clients/[id]/reports/page.tsx rename to app/epd/_archive/clients_backup_20251122/[id]/reports/page.tsx diff --git a/app/epd/clients/actions.ts b/app/epd/_archive/clients_backup_20251122/actions.ts similarity index 100% rename from app/epd/clients/actions.ts rename to app/epd/_archive/clients_backup_20251122/actions.ts diff --git a/app/epd/clients/coming-soon-backup.tsx b/app/epd/_archive/clients_backup_20251122/coming-soon-backup.tsx similarity index 100% rename from app/epd/clients/coming-soon-backup.tsx rename to app/epd/_archive/clients_backup_20251122/coming-soon-backup.tsx diff --git a/app/epd/clients/components/client-form.tsx b/app/epd/_archive/clients_backup_20251122/components/client-form.tsx similarity index 100% rename from app/epd/clients/components/client-form.tsx rename to app/epd/_archive/clients_backup_20251122/components/client-form.tsx diff --git a/app/epd/clients/components/client-list-skeleton.tsx b/app/epd/_archive/clients_backup_20251122/components/client-list-skeleton.tsx similarity index 100% rename from app/epd/clients/components/client-list-skeleton.tsx rename to app/epd/_archive/clients_backup_20251122/components/client-list-skeleton.tsx diff --git a/app/epd/clients/components/client-list.tsx b/app/epd/_archive/clients_backup_20251122/components/client-list.tsx similarity index 100% rename from app/epd/clients/components/client-list.tsx rename to app/epd/_archive/clients_backup_20251122/components/client-list.tsx diff --git a/app/epd/clients/new/page.tsx b/app/epd/_archive/clients_backup_20251122/new/page.tsx similarity index 100% rename from app/epd/clients/new/page.tsx rename to app/epd/_archive/clients_backup_20251122/new/page.tsx diff --git a/app/epd/_archive/clients_backup_20251122/page.tsx b/app/epd/_archive/clients_backup_20251122/page.tsx new file mode 100644 index 0000000..8362f7d --- /dev/null +++ b/app/epd/_archive/clients_backup_20251122/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation'; + +/** + * Clients Root Redirect + * Redirects /epd/clients to /epd/patients for backward compatibility + */ + +export default function ClientsRedirect() { + redirect('/epd/patients'); +} diff --git a/app/epd/clients/[...path]/route.ts b/app/epd/clients/[...path]/route.ts new file mode 100644 index 0000000..982968a --- /dev/null +++ b/app/epd/clients/[...path]/route.ts @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation'; +import { NextRequest } from 'next/server'; + +/** + * Catch-all Redirect Route for /epd/clients/ + * + * Redirects all /epd/clients/* routes to /epd/patients/* for backward compatibility + * This ensures that old bookmarks and links continue to work after migration. + * + * Preserves query parameters (e.g., ?tab=intake&search=test) + */ + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path } = await params; + const searchParams = request.nextUrl.searchParams; + + // Build new path with query parameters + const newPath = `/epd/patients/${path.join('/')}`; + const newUrl = new URL(newPath, request.url); + + // Preserve all query parameters + searchParams.forEach((value, key) => { + newUrl.searchParams.set(key, value); + }); + + redirect(newUrl.toString()); +} + diff --git a/app/epd/clients/page.tsx b/app/epd/clients/page.tsx index 843ca70..8362f7d 100644 --- a/app/epd/clients/page.tsx +++ b/app/epd/clients/page.tsx @@ -1,57 +1,10 @@ -import { Suspense } from 'react'; -import { Plus } from 'lucide-react'; -import { getClients } from './actions'; -import { ClientList } from './components/client-list'; -import { ClientListSkeleton } from './components/client-list-skeleton'; -import Link from 'next/link'; +import { redirect } from 'next/navigation'; -interface SearchParams { - search?: string; - sortBy?: 'name' | 'age' | 'created_at'; - sortOrder?: 'asc' | 'desc'; -} - -export default async function ClientsPage({ - searchParams, -}: { - searchParams: Promise; -}) { - const params = await searchParams; - return ( -
- {/* Page Header */} -
-
-
-

Cliënten

-

- Beheer uw cliëntenbestand -

-
- - - Nieuwe cliënt - -
-
- - {/* Client List with Suspense */} - }> - - -
- ); -} - -async function ClientListWrapper({ searchParams }: { searchParams: SearchParams }) { - const clients = await getClients({ - search: searchParams.search, - sortBy: searchParams.sortBy, - sortOrder: searchParams.sortOrder, - }); - - return ; +/** + * Clients Root Redirect + * Redirects /epd/clients to /epd/patients for backward compatibility + */ + +export default function ClientsRedirect() { + redirect('/epd/patients'); } diff --git a/app/epd/components/epd-header.tsx b/app/epd/components/epd-header.tsx index 5c2bc0a..884b2bb 100644 --- a/app/epd/components/epd-header.tsx +++ b/app/epd/components/epd-header.tsx @@ -2,53 +2,47 @@ import React, { useState, useEffect } from 'react'; import { Search, ChevronDown } from 'lucide-react'; import { usePathname } from 'next/navigation'; -import { getClient } from '../clients/actions'; +import { getPatient } from '../patients/actions'; +import type { FHIRPatient } from '@/lib/fhir'; interface EPDHeaderProps { className?: string; } -interface ClientData { - id: string; - first_name: string; - last_name: string; - birth_date: string; -} - export function EPDHeader({ className = "" }: EPDHeaderProps) { const pathname = usePathname(); - const [selectedClient, setSelectedClient] = useState(null); + const [selectedPatient, setSelectedPatient] = useState(null); const [isLoading, setIsLoading] = useState(false); - // Context detection: Level 2 if URL contains /clients/[id] - const isClientContext = pathname.match(/\/epd\/clients\/[^\/]+/); - const clientId = isClientContext ? pathname.split('/')[3] : null; + // Context detection: Level 2 if URL contains /patients/[id] + const isPatientContext = pathname.match(/\/epd\/patients\/[^\/]+/); + const patientId = isPatientContext ? pathname.split('/')[3] : null; useEffect(() => { - async function fetchClient() { - if (!clientId) { - setSelectedClient(null); + async function fetchPatient() { + if (!patientId) { + setSelectedPatient(null); return; } - // Don't re-fetch if we already have the correct client - if (selectedClient?.id === clientId) return; + // Don't re-fetch if we already have the correct patient + if (selectedPatient?.id === patientId) return; setIsLoading(true); try { - const client = await getClient(clientId); - if (client) { - setSelectedClient(client); + const patient = await getPatient(patientId); + if (patient) { + setSelectedPatient(patient); } } catch (error) { - console.error('Failed to fetch client for header:', error); + console.error('Failed to fetch patient for header:', error); } finally { setIsLoading(false); } } - fetchClient(); - }, [clientId, selectedClient?.id]); + fetchPatient(); + }, [patientId, selectedPatient?.id]); return (
@@ -57,22 +51,22 @@ export function EPDHeader({ className = "" }: EPDHeaderProps) { Mini-ECD - {/* Center: Client Selector (only in Level 2) */} + {/* Center: Patient Selector (only in Level 2) */}
- {(selectedClient || isLoading) && clientId && ( + {(selectedPatient || isLoading) && patientId && ( +
+

{item.content}

+ {item.notes &&

Notities: {item.notes}

} + + ))} + + +
+

Nieuwe anamnese

+ setForm((prev) => ({ ...prev, date: e.target.value }))} + className="h-10 rounded-md border border-slate-300 px-3 text-sm" + /> + +