Cortex handelt een no-show af vanuit één chatcommando: afspraak annuleren (declarabiliteits-nudge), en de openstaande concept- huisartsbrief wordt via LLM herschreven en ter review aangeboden in een document artifact (human-in-the-loop, PATCH dispatch zet status op verzendklaar). - API-routes: context, rescript, cancel, dispatch - NoShowDocumentBlock: review/edit UI met origineel-vergelijk - Mock-data voor concept huisartsbrief - PRD, FO, bouwplan en epics in docs/intent/noshow-case/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 KiB
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()uitlib/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
/**
* 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.
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<typeof CancelNoShowSchema>;
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/cancelmet{ appointmentId: 'mock-appt-noshow-001', patientId: 'demo-patient-001' }geeft{ success: true, newStatus: 'cancelled_no_show' }terug- Geeft
401zonder auth - Geeft
400bij 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.
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-001geeft{ hasConceptBrief: true, document: { ... } }terugGET /api/cortex/noshow/context?patientId=onbekend-idgeeft{ hasConceptBrief: false }terug- Geeft
400zonderpatientIdparam
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.
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<typeof RescriptSchema>;
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/rescriptmet 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: <origineel>, 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.
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<typeof DispatchSchema>;
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/dispatchmet{ documentId: 'mock-brief-noshow-001', finalContent: '...' }geeft{ success: true, newStatus: 'ready_for_dispatch' }terug- Geeft
401zonder auth - Geeft
400bij legefinalContent
Validatie na NS.E3
Test alle routes met de browser devtools of een API client:
# Controleer of de routes bestaan
pnpm build
Snelle curl tests (vervang met een geldig Supabase session token):
# Cancel
curl -X POST http://localhost:3000/api/cortex/noshow/cancel \
-H "Content-Type: application/json" \
-H "Cookie: <supabase-session-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: <supabase-session-cookie>"
# Dispatch
curl -X PATCH http://localhost:3000/api/cortex/noshow/dispatch \
-H "Content-Type: application/json" \
-H "Cookie: <supabase-session-cookie>" \
-d '{"documentId":"mock-brief-noshow-001","finalContent":"test inhoud"}'
Verwacht: alle drie geven { success: true } terug.