# NS.E3 — No-Show API Routes **Casus:** Cortex No Show Afhandeling **Epic doel:** Vier backend routes bouwen die de no-show flow ondersteunen — annuleren, context ophalen, brief herschrijven, en accordering. **Geschatte tijd:** ~3 uur **Afhankelijkheden:** NS.E1 (types), NS.E2.S1 (store types voor type safety in aanroepen) --- ## Context & aanpak Alle routes volgen het bestaande patroon in `app/api/cortex/`: - Auth check via `createClient()` uit `lib/auth/server.ts` - Zod validatie op request body / query params - Nederlandse foutmeldingen in responses - Mock-first: routes checken eerst op bekende mock IDs, dan echte DB **Mock strategie:** De demo moet altijd werken zonder echte database-inhoud. Elke route checkt of de input overeenkomt met mock data (vaste IDs/patientIds). Als dat zo is, return een mock response. Zo is de happy path altijd demonstreerbaar. --- ## NS.E3.S1 — Mock data aanmaken **Nieuw bestand:** `lib/cortex/mock-data/noshow.ts` ```typescript /** * Mock data voor de No Show demo flow. * Gebruikt in alle /api/cortex/noshow/* routes voor de happy-path demo. */ export const MOCK_NO_SHOW_PATIENT_ID = 'demo-patient-001'; export const MOCK_NO_SHOW_APPOINTMENT = { id: 'mock-appt-noshow-001', patientId: MOCK_NO_SHOW_PATIENT_ID, date: new Date().toISOString(), type: 'intake_consult', is_billable: true, status: 'scheduled', title: 'Intake Consult', duration_minutes: 60, } as const; export const MOCK_CONCEPT_BRIEF = { id: 'mock-brief-noshow-001', patientId: MOCK_NO_SHOW_PATIENT_ID, type: 'huisartsbrief', status: 'concept', title: 'Huisartsbrief n.a.v. intake', content: `Geachte collega, Hierbij informeer ik u over de intake van uw patiënt die bij ons in behandeling is gekomen voor ambulante GGZ-zorg. Tijdens de intake is uitvoerig stilgestaan bij de hulpvraag. De patiënt heeft aangegeven al langere tijd last te hebben van stemmingsgerelateerde klachten, waarbij slaapproblemen en concentratieproblemen op de voorgrond staan. Er is sprake van een beperkt sociaal netwerk en recente stresserende levensgebeurtenissen. Op basis van het gesprek en de afgenomen vragenlijsten lijkt er sprake te zijn van een depressieve stoornis, mogelijk in samenhang met een aanpassingsstoornis. Een nadere diagnostische verdieping is aangewezen. Het voorgestelde vervolgtraject bestaat uit wekelijkse individuele gesprekken gericht op stabilisatie, psycho-educatie en het in kaart brengen van de klachten. Ik stel voor om na zes sessies de voortgang te evalueren en u dan nader te informeren. Mocht u vragen hebben of aanvullende informatie willen delen, neemt u dan gerust contact op. Met vriendelijke groet, [Behandelaar naam] GGZ Instelling`, createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 dagen geleden } as const; /** Helper: check of een patientId de mock demo patient is */ export function isMockPatient(patientId: string): boolean { return patientId === MOCK_NO_SHOW_PATIENT_ID; } /** Helper: check of een appointmentId het mock appointment is */ export function isMockAppointment(appointmentId: string): boolean { return appointmentId === MOCK_NO_SHOW_APPOINTMENT.id; } /** Helper: check of een documentId het mock document is */ export function isMockDocument(documentId: string): boolean { return documentId === MOCK_CONCEPT_BRIEF.id; } ``` ### Done criteria - Bestand bestaat en compileert zonder errors - Exports zijn beschikbaar voor import in API routes --- ## NS.E3.S2 — Cancel Route **Nieuw bestand:** `app/api/cortex/noshow/cancel/route.ts` **Doel:** Annuleer een afspraak en registreer het als no-show. ```typescript import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { createClient } from '@/lib/auth/server'; import { isMockAppointment } from '@/lib/cortex/mock-data/noshow'; const CancelNoShowSchema = z.object({ appointmentId: z.string().min(1, 'appointmentId is verplicht'), patientId: z.string().min(1, 'patientId is verplicht'), }); export async function POST(request: NextRequest) { // Auth check const supabase = await createClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); } // Validatie let body: z.infer; try { body = CancelNoShowSchema.parse(await request.json()); } catch (e) { return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 }); } const { appointmentId, patientId } = body; // Mock path — demo flow if (isMockAppointment(appointmentId)) { return NextResponse.json({ success: true, appointmentId, newStatus: 'cancelled_no_show', message: 'Afspraak geregistreerd als no show (demo)', }); } // Productie path — Supabase update // Pas aan naar de juiste tabel in jouw schema (encounters of appointments) const { error } = await supabase .from('encounters') .update({ status: 'cancelled_no_show' }) .eq('id', appointmentId) .eq('patient_id', patientId); if (error) { console.error('[noshow/cancel] DB error:', error); return NextResponse.json({ error: 'Annuleren mislukt' }, { status: 500 }); } return NextResponse.json({ success: true, appointmentId, newStatus: 'cancelled_no_show', }); } ``` ### Done criteria - `POST /api/cortex/noshow/cancel` met `{ appointmentId: 'mock-appt-noshow-001', patientId: 'demo-patient-001' }` geeft `{ success: true, newStatus: 'cancelled_no_show' }` terug - Geeft `401` zonder auth - Geeft `400` bij ontbrekende velden --- ## NS.E3.S3 — Context Route **Nieuw bestand:** `app/api/cortex/noshow/context/route.ts` **Doel:** Check of er openstaande conceptbrieven zijn voor de actieve patiënt. ```typescript import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/auth/server'; import { isMockPatient, MOCK_CONCEPT_BRIEF } from '@/lib/cortex/mock-data/noshow'; export async function GET(request: NextRequest) { // Auth check const supabase = await createClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); } // Query param const { searchParams } = new URL(request.url); const patientId = searchParams.get('patientId'); if (!patientId) { return NextResponse.json({ error: 'patientId is verplicht' }, { status: 400 }); } // Mock path — demo flow if (isMockPatient(patientId)) { return NextResponse.json({ hasConceptBrief: true, document: { id: MOCK_CONCEPT_BRIEF.id, title: MOCK_CONCEPT_BRIEF.title, content: MOCK_CONCEPT_BRIEF.content, type: MOCK_CONCEPT_BRIEF.type, createdAt: MOCK_CONCEPT_BRIEF.createdAt, }, }); } // Productie path — Supabase query // Zoek eerste concept huisartsbrief voor deze patiënt const { data, error } = await supabase .from('reports') .select('id, title, structured_data, type, created_at') .eq('patient_id', patientId) .eq('type', 'huisartsbrief') .eq('status', 'concept') .is('deleted_at', null) .order('created_at', { ascending: false }) .limit(1) .single(); if (error || !data) { return NextResponse.json({ hasConceptBrief: false }); } return NextResponse.json({ hasConceptBrief: true, document: { id: data.id, title: data.title || 'Huisartsbrief', content: data.structured_data?.content ?? '', type: data.type, createdAt: data.created_at, }, }); } ``` ### Done criteria - `GET /api/cortex/noshow/context?patientId=demo-patient-001` geeft `{ hasConceptBrief: true, document: { ... } }` terug - `GET /api/cortex/noshow/context?patientId=onbekend-id` geeft `{ hasConceptBrief: false }` terug - Geeft `400` zonder `patientId` param --- ## NS.E3.S4 — Rescript Route **Nieuw bestand:** `app/api/cortex/noshow/rescript/route.ts` **Doel:** Roep de LLM aan om de huisartsbrief professioneel te herschrijven met de no-show verwerkt. ```typescript import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import Anthropic from '@anthropic-ai/sdk'; import { createClient } from '@/lib/auth/server'; import { isMockDocument } from '@/lib/cortex/mock-data/noshow'; const RescriptSchema = z.object({ documentId: z.string().min(1), originalContent: z.string().min(1, 'Originele inhoud is verplicht'), patientId: z.string().min(1), }); const RESCRIPT_SYSTEM_PROMPT = `Je bent een klinisch schrijver in een GGZ instelling. Je taak is een bestaande conceptbrief aanpassen zodat de no-show van de patiënt professioneel en contextbewust is verwerkt. Regels: - Integreer de no-show in de lopende tekst — voeg het NIET als losse zin achteraan toe - Gebruik formele GGZ-briefstijl: "de patiënt is helaas niet verschenen op het geplande consult" - Bewaar alle bestaande informatie in de brief volledig - Voeg een zin toe over het verzetten van het vervolgcontact - De brief moet leesbaar en coherent blijven - Reageer UITSLUITEND met de herschreven brieftekst — geen uitleg, geen inleiding`; export async function POST(request: NextRequest) { // Auth check const supabase = await createClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); } // Validatie let body: z.infer; try { body = RescriptSchema.parse(await request.json()); } catch { return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 }); } const { documentId, originalContent } = body; // LLM aanroepen (zowel voor mock als productie — we willen altijd echte AI output) try { const client = new Anthropic(); const response = await client.messages.create({ model: 'claude-haiku-4-5-20251001', max_tokens: 1500, system: RESCRIPT_SYSTEM_PROMPT, messages: [ { role: 'user', content: `Herschrijf de volgende conceptbrief zodat de no-show van vandaag professioneel is verwerkt:\n\n${originalContent}`, }, ], }); const rescriptedContent = response.content[0].type === 'text' ? response.content[0].text : originalContent; // Fallback naar origineel return NextResponse.json({ success: true, documentId, rescriptedContent, originalContent, // Meesturen zodat de UI "origineel bekijken" kan tonen }); } catch (error) { console.error('[noshow/rescript] LLM error:', error); // Graceful degradation: geef originele inhoud terug met waarschuwing return NextResponse.json({ success: false, documentId, rescriptedContent: originalContent, // Origineel als fallback originalContent, warning: 'AI herschrijving mislukt — originele tekst wordt getoond', }); } } ``` **Noot over model:** We gebruiken `claude-haiku-4-5-20251001` (snel + goedkoop) voor de rescript taak. Als de kwaliteit onvoldoende is, schakel over naar `claude-sonnet-4-6`. **Noot over fallback:** De route geeft altijd `200` terug — ook bij LLM fout. Het `success: false` veld + `warning` laat de UI zien dat de AI niet werkte, maar de brief is alsnog bewerkbaar (met originele inhoud). ### Done criteria - `POST /api/cortex/noshow/rescript` met mock document content geeft herschreven tekst terug - De herschreven tekst is langer dan de originele (no-show is toegevoegd) - Bij Anthropic API fout: `{ success: false, rescriptedContent: , warning: '...' }` - Response tijd < 10 seconden (Haiku is snel) --- ## NS.E3.S5 — Dispatch Route **Nieuw bestand:** `app/api/cortex/noshow/dispatch/route.ts` **Doel:** Document status naar `ready_for_dispatch` zetten en definitieve inhoud opslaan. ```typescript import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { createClient } from '@/lib/auth/server'; import { isMockDocument } from '@/lib/cortex/mock-data/noshow'; const DispatchSchema = z.object({ documentId: z.string().min(1), finalContent: z.string().min(1, 'Definitieve inhoud is verplicht'), }); export async function PATCH(request: NextRequest) { // Auth check const supabase = await createClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }); } // Validatie let body: z.infer; try { body = DispatchSchema.parse(await request.json()); } catch { return NextResponse.json({ error: 'Ongeldige invoer' }, { status: 400 }); } const { documentId, finalContent } = body; // Mock path — demo flow if (isMockDocument(documentId)) { return NextResponse.json({ success: true, documentId, newStatus: 'ready_for_dispatch', message: 'Brief verzendklaar gemaakt (demo)', }); } // Productie path — Supabase update const { error } = await supabase .from('reports') .update({ status: 'ready_for_dispatch', // Sla de definitieve inhoud op — pas het veld aan naar het juiste kolom in je schema structured_data: { content: finalContent }, updated_at: new Date().toISOString(), }) .eq('id', documentId) .eq('user_id', user.id); // Extra veiligheidscheck — alleen eigen documenten if (error) { console.error('[noshow/dispatch] DB error:', error); return NextResponse.json({ error: 'Opslaan mislukt' }, { status: 500 }); } return NextResponse.json({ success: true, documentId, newStatus: 'ready_for_dispatch', }); } ``` ### Done criteria - `PATCH /api/cortex/noshow/dispatch` met `{ documentId: 'mock-brief-noshow-001', finalContent: '...' }` geeft `{ success: true, newStatus: 'ready_for_dispatch' }` terug - Geeft `401` zonder auth - Geeft `400` bij lege `finalContent` --- ## Validatie na NS.E3 Test alle routes met de browser devtools of een API client: ```bash # Controleer of de routes bestaan pnpm build ``` **Snelle curl tests (vervang met een geldig Supabase session token):** ```bash # Cancel curl -X POST http://localhost:3000/api/cortex/noshow/cancel \ -H "Content-Type: application/json" \ -H "Cookie: " \ -d '{"appointmentId":"mock-appt-noshow-001","patientId":"demo-patient-001"}' # Context curl "http://localhost:3000/api/cortex/noshow/context?patientId=demo-patient-001" \ -H "Cookie: " # Dispatch curl -X PATCH http://localhost:3000/api/cortex/noshow/dispatch \ -H "Content-Type: application/json" \ -H "Cookie: " \ -d '{"documentId":"mock-brief-noshow-001","finalContent":"test inhoud"}' ``` Verwacht: alle drie geven `{ success: true }` terug.