refactor: rename swift → cortex in code and documentation
Complete rename of "swift" terminology to "cortex" across the codebase: Code Changes: - Rename directories: lib/swift → lib/cortex, components/swift → components/cortex - Rename API routes: /api/swift/* → /api/cortex/* - Rename page routes: /epd/swift → /epd/cortex - Rename store: swift-store.ts → cortex-store.ts Type Renames: - SwiftIntent → CortexIntent - SwiftStore → CortexStore - useSwiftStore → useCortexStore - SwiftContext → CortexContext - useSwiftVoice → useCortexVoice Documentation Updates (docs/intent/): - Safety Net → Nudge (Layer 3 rename) - SafetySuggestion → NudgeSuggestion - evaluateSafetyNet → evaluateNudge - SWIFT_* feature flags → CORTEX_* - All path references updated to match new structure Files affected: 47 files, ~700 lines changed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
104
app/api/cortex/agenda/cancel/route.ts
Normal file
104
app/api/cortex/agenda/cancel/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { cancelEncounter } from '@/app/epd/agenda/actions';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Swift Cancel Appointment API
|
||||
*
|
||||
* POST /api/swift/agenda/cancel
|
||||
*
|
||||
* Cancels an appointment (soft delete: status → 'cancelled').
|
||||
*/
|
||||
|
||||
// Request body schema
|
||||
const CancelAppointmentSchema = z.object({
|
||||
encounterId: z.string().uuid({ message: 'encounterId moet een geldige UUID zijn' }),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Niet geautoriseerd. Log opnieuw in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json();
|
||||
const validation = CancelAppointmentSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
const errorMessage = validation.error.issues
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
const { encounterId } = validation.data;
|
||||
|
||||
// Verify the encounter belongs to the current user (security check)
|
||||
const { data: encounter, error: fetchError } = await supabase
|
||||
.from('encounters')
|
||||
.select('id, practitioner_id, patient_id, period_start, status')
|
||||
.eq('id', encounterId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !encounter) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Afspraak niet gevonden' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (encounter.practitioner_id !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Je hebt geen toegang tot deze afspraak' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (encounter.status === 'cancelled') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Deze afspraak is al geannuleerd' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel the encounter
|
||||
const result = await cancelEncounter(encounterId);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Fout bij het annuleren van de afspraak' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
encounterId,
|
||||
message: 'Afspraak succesvol geannuleerd',
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in cancel appointment API:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Er ging iets mis bij het annuleren van de afspraak. Probeer het opnieuw.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
165
app/api/cortex/agenda/create/route.ts
Normal file
165
app/api/cortex/agenda/create/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { createEncounter } from '@/app/epd/agenda/actions';
|
||||
import { z } from 'zod';
|
||||
import { addHours } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Swift Create Appointment API
|
||||
*
|
||||
* POST /api/swift/agenda/create
|
||||
*
|
||||
* Creates a new appointment (encounter).
|
||||
*/
|
||||
|
||||
// Type mappings (appointment type → FHIR encounter type)
|
||||
const TYPE_MAPPINGS: Record<
|
||||
string,
|
||||
{ code: string; display: string }
|
||||
> = {
|
||||
intake: { code: 'intake', display: 'Intake' },
|
||||
behandeling: { code: 'behandeling', display: 'Behandeling' },
|
||||
'follow-up': { code: 'follow-up', display: 'Follow-up' },
|
||||
telefonisch: { code: 'telefonisch', display: 'Telefonisch' },
|
||||
huisbezoek: { code: 'huisbezoek', display: 'Huisbezoek' },
|
||||
online: { code: 'online', display: 'Online' },
|
||||
crisis: { code: 'crisis', display: 'Crisis' },
|
||||
overig: { code: 'overig', display: 'Overig' },
|
||||
};
|
||||
|
||||
// Location mappings (location → FHIR class code)
|
||||
const LOCATION_MAPPINGS: Record<
|
||||
string,
|
||||
{ code: string; display: string }
|
||||
> = {
|
||||
praktijk: { code: 'AMB', display: 'Ambulant (praktijk)' },
|
||||
online: { code: 'VR', display: 'Virtual (online)' },
|
||||
thuis: { code: 'HH', display: 'Home (thuis)' },
|
||||
};
|
||||
|
||||
// Request body schema
|
||||
const CreateAppointmentSchema = z.object({
|
||||
patientId: z.string().uuid({ message: 'patientId moet een geldige UUID zijn' }),
|
||||
datetime: z.object({
|
||||
date: z.string().refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: 'datetime.date moet een geldige datum zijn',
|
||||
}),
|
||||
time: z
|
||||
.string()
|
||||
.regex(/^\d{2}:\d{2}$/, { message: 'datetime.time moet HH:mm formaat zijn' }),
|
||||
}),
|
||||
type: z.enum(
|
||||
[
|
||||
'intake',
|
||||
'behandeling',
|
||||
'follow-up',
|
||||
'telefonisch',
|
||||
'huisbezoek',
|
||||
'online',
|
||||
'crisis',
|
||||
'overig',
|
||||
],
|
||||
{ message: 'type moet een geldig afspraaktype zijn' }
|
||||
),
|
||||
location: z.enum(['praktijk', 'online', 'thuis'], {
|
||||
message: 'location moet praktijk, online of thuis zijn',
|
||||
}),
|
||||
notes: z.string().max(500, { message: 'Notities mogen maximaal 500 tekens bevatten' }).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Niet geautoriseerd. Log opnieuw in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json();
|
||||
const validation = CreateAppointmentSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
const errorMessage = validation.error.issues
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
const { patientId, datetime, type, location, notes } = validation.data;
|
||||
|
||||
// Combine date and time into ISO datetime
|
||||
const [hours, minutes] = datetime.time.split(':').map(Number);
|
||||
const startDate = new Date(datetime.date);
|
||||
startDate.setHours(hours, minutes, 0, 0);
|
||||
|
||||
// Default duration: 1 hour
|
||||
const endDate = addHours(startDate, 1);
|
||||
|
||||
// Validate date is not in the past
|
||||
const now = new Date();
|
||||
if (startDate < now) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan geen afspraken in het verleden maken' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Map type and location
|
||||
const typeMapping = TYPE_MAPPINGS[type];
|
||||
const locationMapping = LOCATION_MAPPINGS[location];
|
||||
|
||||
// Create encounter
|
||||
const result = await createEncounter({
|
||||
patientId,
|
||||
practitionerId: user.id,
|
||||
periodStart: startDate.toISOString(),
|
||||
periodEnd: endDate.toISOString(),
|
||||
typeCode: typeMapping.code,
|
||||
typeDisplay: typeMapping.display,
|
||||
classCode: locationMapping.code,
|
||||
classDisplay: locationMapping.display,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Fout bij het aanmaken van de afspraak' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
encounterId: result.data?.id,
|
||||
appointment: {
|
||||
id: result.data?.id,
|
||||
patientId,
|
||||
periodStart: startDate.toISOString(),
|
||||
periodEnd: endDate.toISOString(),
|
||||
type: typeMapping.display,
|
||||
location: locationMapping.display,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in create appointment API:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Er ging iets mis bij het aanmaken van de afspraak. Probeer het opnieuw.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
148
app/api/cortex/agenda/reschedule/route.ts
Normal file
148
app/api/cortex/agenda/reschedule/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { rescheduleEncounter } from '@/app/epd/agenda/actions';
|
||||
import { z } from 'zod';
|
||||
import { addHours } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Swift Reschedule Appointment API
|
||||
*
|
||||
* POST /api/swift/agenda/reschedule
|
||||
*
|
||||
* Reschedules an appointment to a new date/time.
|
||||
*/
|
||||
|
||||
// Request body schema
|
||||
const RescheduleAppointmentSchema = z.object({
|
||||
encounterId: z.string().uuid({ message: 'encounterId moet een geldige UUID zijn' }),
|
||||
newDatetime: z.object({
|
||||
date: z.string().refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: 'newDatetime.date moet een geldige datum zijn',
|
||||
}),
|
||||
time: z
|
||||
.string()
|
||||
.regex(/^\d{2}:\d{2}$/, { message: 'newDatetime.time moet HH:mm formaat zijn' }),
|
||||
}),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Niet geautoriseerd. Log opnieuw in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json();
|
||||
const validation = RescheduleAppointmentSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
const errorMessage = validation.error.issues
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
const { encounterId, newDatetime } = validation.data;
|
||||
|
||||
// Verify the encounter belongs to the current user (security check)
|
||||
const { data: encounter, error: fetchError } = await supabase
|
||||
.from('encounters')
|
||||
.select('id, practitioner_id, patient_id, period_start, period_end, status')
|
||||
.eq('id', encounterId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !encounter) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Afspraak niet gevonden' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (encounter.practitioner_id !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Je hebt geen toegang tot deze afspraak' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (encounter.status === 'cancelled') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan een geannuleerde afspraak niet verzetten' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Combine new date and time into ISO datetime
|
||||
const [hours, minutes] = newDatetime.time.split(':').map(Number);
|
||||
const newStartDate = new Date(newDatetime.date);
|
||||
newStartDate.setHours(hours, minutes, 0, 0);
|
||||
|
||||
// Validate new date is not in the past
|
||||
const now = new Date();
|
||||
if (newStartDate < now) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan geen afspraken in het verleden verzetten' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate duration from original appointment
|
||||
let newEndDate: Date;
|
||||
if (encounter.period_end) {
|
||||
const originalStart = new Date(encounter.period_start);
|
||||
const originalEnd = new Date(encounter.period_end);
|
||||
const durationMs = originalEnd.getTime() - originalStart.getTime();
|
||||
newEndDate = new Date(newStartDate.getTime() + durationMs);
|
||||
} else {
|
||||
// Default: 1 hour
|
||||
newEndDate = addHours(newStartDate, 1);
|
||||
}
|
||||
|
||||
// Reschedule the encounter
|
||||
const result = await rescheduleEncounter(
|
||||
encounterId,
|
||||
newStartDate.toISOString(),
|
||||
newEndDate.toISOString()
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Fout bij het verzetten van de afspraak' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
encounterId,
|
||||
appointment: {
|
||||
id: encounterId,
|
||||
periodStart: newStartDate.toISOString(),
|
||||
periodEnd: newEndDate.toISOString(),
|
||||
},
|
||||
message: 'Afspraak succesvol verzet',
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in reschedule appointment API:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Er ging iets mis bij het verzetten van de afspraak. Probeer het opnieuw.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
86
app/api/cortex/agenda/route.ts
Normal file
86
app/api/cortex/agenda/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { getEncounters } from '@/app/epd/agenda/actions';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Swift Agenda Query API
|
||||
*
|
||||
* GET /api/swift/agenda?start=2024-12-27&end=2024-12-27
|
||||
*
|
||||
* Returns appointments for the specified date range.
|
||||
* Automatically filters by current user (practitioner_id).
|
||||
*/
|
||||
|
||||
// Query parameter schema
|
||||
const QuerySchema = z.object({
|
||||
start: z.string().refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: 'start moet een geldige datum zijn',
|
||||
}),
|
||||
end: z.string().refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: 'end moet een geldige datum zijn',
|
||||
}),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Niet geautoriseerd. Log opnieuw in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate query parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const start = searchParams.get('start');
|
||||
const end = searchParams.get('end');
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json(
|
||||
{ error: 'start en end parameters zijn verplicht' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validation = QuerySchema.safeParse({ start, end });
|
||||
if (!validation.success) {
|
||||
const errorMessage = validation.error.issues
|
||||
.map((e) => e.message)
|
||||
.join(', ');
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch appointments
|
||||
const appointments = await getEncounters({
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
practitionerId: user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
appointments,
|
||||
count: appointments.length,
|
||||
dateRange: { start, end },
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in agenda query API:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Er ging iets mis bij het ophalen van afspraken. Probeer het opnieuw.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
673
app/api/cortex/chat/route.ts
Normal file
673
app/api/cortex/chat/route.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Cortex Chat API Route (v3.0)
|
||||
*
|
||||
* Streaming chat endpoint voor Cortex Assistent met Server-Sent Events (SSE).
|
||||
*
|
||||
* Epic: E3 (Chat API & Cortex Assistent)
|
||||
* Story: E3.S1 (Chat API endpoint skeleton)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getSession } from '@/lib/auth/server';
|
||||
import type { ChatMessage as ChatMessageType, ChatAction } from '@/stores/cortex-store';
|
||||
|
||||
// Configuration
|
||||
const CORTEX_MODEL = process.env.CORTEX_MODEL ?? 'claude-sonnet-4-20250514';
|
||||
const MAX_HISTORY_MESSAGES = 20;
|
||||
const MAX_USER_MESSAGE_LENGTH = 2000;
|
||||
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const RATE_LIMIT_MAX_REQUESTS = 20; // max requests per window
|
||||
|
||||
// In-memory rate limit store (resets on server restart)
|
||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||
|
||||
function checkRateLimit(userId: string): { allowed: boolean; remaining: number; resetIn: number } {
|
||||
const now = Date.now();
|
||||
const record = rateLimitStore.get(userId);
|
||||
|
||||
// Clean up expired entries periodically
|
||||
if (rateLimitStore.size > 1000) {
|
||||
for (const [key, value] of rateLimitStore.entries()) {
|
||||
if (value.resetTime < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!record || record.resetTime < now) {
|
||||
// New window
|
||||
rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS });
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1, resetIn: RATE_LIMIT_WINDOW_MS };
|
||||
}
|
||||
|
||||
if (record.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||
return { allowed: false, remaining: 0, resetIn: record.resetTime - now };
|
||||
}
|
||||
|
||||
record.count++;
|
||||
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - record.count, resetIn: record.resetTime - now };
|
||||
}
|
||||
|
||||
// Request validation schemas
|
||||
const ChatMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['user', 'assistant', 'system', 'error']),
|
||||
content: z.string().min(1),
|
||||
timestamp: z.string().or(z.date()),
|
||||
action: z.any().optional(), // ChatAction is optional
|
||||
});
|
||||
|
||||
const RequestSchema = z.object({
|
||||
message: z.string().min(1).max(MAX_USER_MESSAGE_LENGTH),
|
||||
messages: z.array(ChatMessageSchema).optional(),
|
||||
context: z.object({
|
||||
activePatient: z.object({
|
||||
id: z.string(),
|
||||
first_name: z.string(),
|
||||
last_name: z.string(),
|
||||
}).optional().nullable(),
|
||||
shift: z.enum(['nacht', 'ochtend', 'middag', 'avond']),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
type RequestData = z.infer<typeof RequestSchema>;
|
||||
|
||||
/**
|
||||
* Build Cortex Assistent system prompt (E3.S3)
|
||||
* Full prompt with intent detection, entity extraction, and action generation
|
||||
*/
|
||||
function buildSystemPrompt(context?: RequestData['context']): string {
|
||||
// Build context section
|
||||
const patientContext = context?.activePatient
|
||||
? `{
|
||||
"id": "${context.activePatient.id}",
|
||||
"name": "${context.activePatient.first_name} ${context.activePatient.last_name}",
|
||||
"firstName": "${context.activePatient.first_name}",
|
||||
"lastName": "${context.activePatient.last_name}"
|
||||
}`
|
||||
: 'null';
|
||||
|
||||
const shiftContext = context?.shift ?? 'ochtend';
|
||||
|
||||
return `Je bent Cortex Assistent, een medische assistent voor Cortex EPD, een Nederlands EPD-systeem voor GGZ-instellingen.
|
||||
|
||||
## Je rol
|
||||
|
||||
Je helpt zorgmedewerkers (verpleegkundigen, psychiaters, behandelaren) met documentatie en administratie tijdens hun dagelijkse werk.
|
||||
|
||||
### Kernkwaliteiten:
|
||||
- **Natuurlijk Nederlands**: Je spreekt vloeiend, informeel maar professioneel Nederlands
|
||||
- **Begrijpend**: Je begrijpt context en kan doorvragen
|
||||
- **Efficiënt**: Je helpt snel zonder onnodige uitleg
|
||||
- **Betrouwbaar**: Je maakt geen aannames, maar vraagt bij twijfel
|
||||
|
||||
### Tone of voice:
|
||||
- Vriendelijk en behulpzaam (zoals een collega)
|
||||
- Professioneel en respectvol
|
||||
- Kort en to-the-point (geen lange uitleg)
|
||||
- Empatisch voor werkdruk zorgmedewerkers
|
||||
|
||||
## Wat je DOET
|
||||
|
||||
### 1. Intents herkennen
|
||||
|
||||
Je herkent de volgende gebruikersintenties en voert acties uit:
|
||||
|
||||
**P1 Intents (kritiek, hoogfrequent):**
|
||||
|
||||
- **dagnotitie** — Verpleegkundige wil snelle notitie maken
|
||||
- Triggers: "notitie [patient]", "medicatie gegeven", "[patient] heeft...", "incident bij [patient]"
|
||||
- Entities: patientName (naam), category (medicatie/adl/gedrag/incident/observatie), content (tekst)
|
||||
|
||||
- **zoeken** — Gebruiker zoekt patiënt
|
||||
- Triggers: "zoek [naam]", "wie is [naam]", "vind [naam]", "patient [naam]"
|
||||
- Entities: query (zoekterm)
|
||||
|
||||
- **overdracht** — Dienst overdracht maken
|
||||
- Triggers: "overdracht", "dienst overdracht", "maak overdracht", "wat moet ik weten"
|
||||
- Entities: shift (optioneel: ochtend/middag/avond/nacht)
|
||||
|
||||
**P2 Intents (belangrijk, middenfrequent):**
|
||||
|
||||
- **rapportage** — Behandelrapportage schrijven
|
||||
- Triggers: "rapportage", "gesprek gehad", "behandelgesprek", "evaluatie"
|
||||
- Entities: patientName (naam), type (optioneel: gesprek/evaluatie/consult)
|
||||
|
||||
- **agenda_query** — Afspraken opvragen
|
||||
- Triggers: "afspraken vandaag", "agenda morgen", "wat is mijn volgende afspraak", "afspraken deze week"
|
||||
- Entities: dateRange (vandaag/morgen/deze week/volgende week)
|
||||
- Actie: Toon lijst van afspraken in AgendaBlock
|
||||
|
||||
- **create_appointment** — Nieuwe afspraak maken
|
||||
- Triggers: "maak afspraak [patient]", "plan intake [patient]", "afspraak maken met [patient] [datum] [tijd]"
|
||||
- Entities: patientName (naam), datetime (datum + tijd), appointmentType (intake/behandeling/follow-up/telefonisch/huisbezoek/online/crisis), location (praktijk/online/thuis)
|
||||
- Required: patientName OF patientId, datetime
|
||||
- Optional: appointmentType (default: behandeling), location (default: praktijk)
|
||||
- Actie: Open create form met pre-fill
|
||||
|
||||
- **cancel_appointment** — Afspraak annuleren
|
||||
- Triggers: "annuleer afspraak [patient]", "cancel [tijd]", "afspraak van [patient] annuleren"
|
||||
- Entities: identifier (patient naam/tijd combinatie voor matching)
|
||||
- Actie: Toon confirmation dialog, bij meerdere matches: disambiguation
|
||||
|
||||
- **reschedule_appointment** — Afspraak verzetten
|
||||
- Triggers: "verzet afspraak [patient]", "verzet [oude tijd] naar [nieuwe tijd]", "[patient] naar [nieuwe datum]"
|
||||
- Entities: identifier (patient naam/tijd voor matching), newDatetime (nieuwe datum + tijd)
|
||||
- Required: identifier
|
||||
- Actie: Toon edit form met oude en nieuwe tijd
|
||||
|
||||
### 2. Verduidelijkingsvragen stellen
|
||||
|
||||
Als je twijfelt over de intent of belangrijke informatie mist:
|
||||
|
||||
**Vraag om verduidelijking:**
|
||||
- "Met welke patiënt had je het gesprek?" (patient ontbreekt)
|
||||
- "Wil je een notitie maken of de overdracht bekijken?" (intent onduidelijk)
|
||||
- "Bedoel je Jan de Vries of Jan Bakker?" (meerdere matches)
|
||||
- "Voor welke datum wil je de afspraak maken?" (datum ontbreekt bij create_appointment)
|
||||
- "Op welk tijdstip?" (tijd ontbreekt bij create_appointment)
|
||||
- "Welke afspraak wil je verzetten?" (identifier onduidelijk bij reschedule/cancel)
|
||||
|
||||
**Bevestig interpretatie:**
|
||||
- "Ik maak een dagnotitie voor Jan de Vries. Categorie: Medicatie. Klopt dat?"
|
||||
- "Je wilt de overdracht voor de ochtend. Correct?"
|
||||
|
||||
### 3. Action objects genereren
|
||||
|
||||
Wanneer je een intent herkent EN voldoende informatie hebt, genereer je een JSON action object aan het einde van je response:
|
||||
|
||||
**Format:**
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "action",
|
||||
"intent": "dagnotitie",
|
||||
"entities": {
|
||||
"patientName": "Jan de Vries",
|
||||
"patientId": "uuid-here",
|
||||
"category": "medicatie",
|
||||
"content": "Medicatie uitgereikt volgens schema"
|
||||
},
|
||||
"confidence": 0.95,
|
||||
"artifact": {
|
||||
"type": "dagnotitie",
|
||||
"prefill": {
|
||||
"patientName": "Jan de Vries",
|
||||
"patientId": "uuid-here",
|
||||
"category": "medicatie",
|
||||
"content": "Medicatie uitgereikt volgens schema"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Confidence thresholds:**
|
||||
- \`>0.9\` → Open artifact direct met prefill
|
||||
- \`0.7-0.9\` → Open artifact + bevestigingsvraag
|
||||
- \`0.5-0.7\` → Verduidelijkingsvraag, geen artifact
|
||||
- \`<0.5\` → "Ik begrijp het niet helemaal. Kun je het anders zeggen?"
|
||||
|
||||
**BELANGRIJK voor JSON formatting:**
|
||||
- Plaats het JSON object ALTIJD aan het einde van je response
|
||||
- Voorzie het JSON object met precies drie backticks op een nieuwe regel: \`\`\`json
|
||||
- Sluit het JSON object af met precies drie backticks op een nieuwe regel: \`\`\`
|
||||
- Zorg dat het JSON geldig is (valid JSON syntax)
|
||||
|
||||
### 4. Follow-up conversatie
|
||||
|
||||
Gebruikers kunnen doorvragen of aanvullen. Behoud context uit eerdere messages.
|
||||
|
||||
## Wat je NIET doet
|
||||
|
||||
❌ **Geen medisch advies geven**
|
||||
- Je bent assistent voor documentatie, geen diagnostische tool
|
||||
- Bij medische vragen: "Daarvoor moet je de behandelaar raadplegen."
|
||||
|
||||
❌ **Geen aannames over patiënten**
|
||||
- Als patiënt naam onduidelijk is → vraag om verduidelijking
|
||||
- Als meerdere patiënten matchen → vraag welke bedoeld wordt
|
||||
|
||||
❌ **Geen lange uitleg**
|
||||
- Houd antwoorden kort en to-the-point
|
||||
- Geen "Ik ga nu..." of "Laat me even kijken..." (doe gewoon)
|
||||
|
||||
❌ **Geen ongevraagde acties**
|
||||
- Maak geen notities zonder expliciete opdracht
|
||||
- Geen notities automatisch opslaan (gebruiker beslist)
|
||||
|
||||
## Context die beschikbaar is
|
||||
|
||||
Je hebt toegang tot de volgende context:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"activePatient": ${patientContext},
|
||||
"shift": "${shiftContext}"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Gebruik context slim:**
|
||||
- Als \`activePatient\` bekend is, gebruik die als default
|
||||
- Bij "maak notitie" zonder patiënt naam → gebruik activePatient
|
||||
- Bij twijfel over welke patiënt → vraag om bevestiging
|
||||
|
||||
## Voorbeelden
|
||||
|
||||
### Voorbeeld 1: Dagnotitie (Happy Path)
|
||||
|
||||
**User:**
|
||||
"Medicatie gegeven aan Jan"
|
||||
|
||||
**AI Response:**
|
||||
"Ik maak een dagnotitie voor Jan. Categorie: Medicatie. Wil je nog iets toevoegen?
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "action",
|
||||
"intent": "dagnotitie",
|
||||
"entities": {
|
||||
"patientName": "Jan",
|
||||
"category": "medicatie",
|
||||
"content": "Medicatie gegeven"
|
||||
},
|
||||
"confidence": 0.95,
|
||||
"artifact": {
|
||||
"type": "dagnotitie",
|
||||
"prefill": {
|
||||
"patientName": "Jan",
|
||||
"category": "medicatie",
|
||||
"content": "Medicatie gegeven"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`"
|
||||
|
||||
### Voorbeeld 2: Patiënt zoeken
|
||||
|
||||
**User:**
|
||||
"Wie is Marie van den Berg?"
|
||||
|
||||
**AI Response:**
|
||||
"Ik zoek Marie van den Berg voor je.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "action",
|
||||
"intent": "zoeken",
|
||||
"entities": {
|
||||
"query": "Marie van den Berg"
|
||||
},
|
||||
"confidence": 0.98,
|
||||
"artifact": {
|
||||
"type": "zoeken",
|
||||
"prefill": {
|
||||
"query": "Marie van den Berg"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`"
|
||||
|
||||
### Voorbeeld 3: Verduidelijkingsvraag
|
||||
|
||||
**User:**
|
||||
"Ik heb een gesprek gehad"
|
||||
|
||||
**AI Response:**
|
||||
"Met welke patiënt had je het gesprek?"
|
||||
|
||||
(Geen JSON action omdat patient ontbreekt, confidence < 0.5)
|
||||
|
||||
### Voorbeeld 4: Onduidelijke intent
|
||||
|
||||
**User:**
|
||||
"Jan"
|
||||
|
||||
**AI Response:**
|
||||
"Wat wil je doen met Jan? Je kunt bijvoorbeeld:
|
||||
- Een notitie maken
|
||||
- Het dossier openen
|
||||
- Een rapportage schrijven"
|
||||
|
||||
(Geen JSON action omdat intent onduidelijk is)
|
||||
|
||||
### Voorbeeld 5: Agenda query
|
||||
|
||||
**User:**
|
||||
"Afspraken vandaag"
|
||||
|
||||
**AI Response:**
|
||||
"Ik toon je de afspraken voor vandaag.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "action",
|
||||
"intent": "agenda_query",
|
||||
"entities": {
|
||||
"dateRange": {
|
||||
"start": "2025-12-27",
|
||||
"end": "2025-12-27",
|
||||
"label": "vandaag"
|
||||
}
|
||||
},
|
||||
"confidence": 0.98,
|
||||
"artifact": {
|
||||
"type": "agenda_query",
|
||||
"prefill": {
|
||||
"dateRange": {
|
||||
"start": "2025-12-27",
|
||||
"end": "2025-12-27",
|
||||
"label": "vandaag"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`"
|
||||
|
||||
### Voorbeeld 6: Afspraak maken (compleet)
|
||||
|
||||
**User:**
|
||||
"Maak afspraak met Jan morgen 14:00"
|
||||
|
||||
**AI Response:**
|
||||
"Ik maak een afspraak voor Jan morgen om 14:00.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "action",
|
||||
"intent": "create_appointment",
|
||||
"entities": {
|
||||
"patientName": "Jan",
|
||||
"datetime": {
|
||||
"date": "2025-12-28",
|
||||
"time": "14:00"
|
||||
},
|
||||
"appointmentType": "behandeling",
|
||||
"location": "praktijk"
|
||||
},
|
||||
"confidence": 0.95,
|
||||
"artifact": {
|
||||
"type": "create_appointment",
|
||||
"prefill": {
|
||||
"patientName": "Jan",
|
||||
"datetime": {
|
||||
"date": "2025-12-28",
|
||||
"time": "14:00"
|
||||
},
|
||||
"appointmentType": "behandeling",
|
||||
"location": "praktijk"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`"
|
||||
|
||||
### Voorbeeld 7: Afspraak maken (incompleet)
|
||||
|
||||
**User:**
|
||||
"Plan intake Marie"
|
||||
|
||||
**AI Response:**
|
||||
"Voor welke datum en tijd wil je de intake voor Marie plannen?"
|
||||
|
||||
(Geen JSON action omdat datetime ontbreekt, confidence < 0.7)
|
||||
|
||||
### Voorbeeld 8: Afspraak verzetten
|
||||
|
||||
**User:**
|
||||
"Verzet 14:00 naar 15:00"
|
||||
|
||||
**AI Response:**
|
||||
"Ik verzet de afspraak van 14:00 naar 15:00.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "action",
|
||||
"intent": "reschedule_appointment",
|
||||
"entities": {
|
||||
"identifier": {
|
||||
"type": "time",
|
||||
"time": "14:00"
|
||||
},
|
||||
"newDatetime": {
|
||||
"date": "2025-12-27",
|
||||
"time": "15:00"
|
||||
}
|
||||
},
|
||||
"confidence": 0.92,
|
||||
"artifact": {
|
||||
"type": "reschedule_appointment",
|
||||
"prefill": {
|
||||
"identifier": {
|
||||
"type": "time",
|
||||
"time": "14:00"
|
||||
},
|
||||
"newDatetime": {
|
||||
"date": "2025-12-27",
|
||||
"time": "15:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`"
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Onbekende intent
|
||||
|
||||
"Ik begrijp niet helemaal wat je wilt doen. Probeer bijvoorbeeld:
|
||||
- \"Notitie maken voor [patient]\"
|
||||
- \"Zoek [patient]\"
|
||||
- \"Maak overdracht\""
|
||||
|
||||
### Geen patient gevonden
|
||||
|
||||
"Ik kan geen patiënt vinden met die naam. Wil je de naam anders spellen of een andere patiënt zoeken?"
|
||||
|
||||
---
|
||||
|
||||
**BELANGRIJK**: Genereer alleen JSON action objects wanneer je confidence ≥ 0.7 hebt. Bij lagere confidence: stel verduidelijkingsvragen.`;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 1. Authentication check
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Niet geauthenticeerd' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Rate limit check
|
||||
const rateLimit = checkRateLimit(session.user.id);
|
||||
if (!rateLimit.allowed) {
|
||||
const resetInSeconds = Math.ceil(rateLimit.resetIn / 1000);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Te veel verzoeken. Probeer het over ${resetInSeconds} seconden opnieuw.`,
|
||||
resetIn: resetInSeconds,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': String(Math.ceil(Date.now() / 1000) + resetInSeconds),
|
||||
'Retry-After': String(resetInSeconds),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Request body validation
|
||||
const json = await request.json();
|
||||
const parsed = RequestSchema.safeParse(json);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Validatiefout',
|
||||
details: parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.') || 'message',
|
||||
message: issue.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { message, messages = [], context } = parsed.data;
|
||||
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage) {
|
||||
return NextResponse.json({ error: 'Bericht mag niet leeg zijn' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 4. Prepare conversation history (limit to last N messages)
|
||||
const history = messages.slice(-MAX_HISTORY_MESSAGES);
|
||||
|
||||
// 5. Build Cortex Assistent system prompt (E3.S3)
|
||||
const systemPrompt = buildSystemPrompt(context);
|
||||
|
||||
// 6. Check for Claude API key
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'ANTHROPIC_API_KEY ontbreekt' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 7. Call Claude API with streaming
|
||||
const anthropicResponse = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: CORTEX_MODEL,
|
||||
max_tokens: 2048,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
...history.map((msg) => ({
|
||||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content,
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: trimmedMessage,
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: request.signal,
|
||||
});
|
||||
|
||||
if (!anthropicResponse.ok || !anthropicResponse.body) {
|
||||
const errorText = await anthropicResponse.text().catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Claude API error',
|
||||
details: errorText ?? anthropicResponse.statusText,
|
||||
},
|
||||
{ status: anthropicResponse.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Parse Claude streaming response and forward as SSE
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const reader = anthropicResponse.body!.getReader();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode chunk
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.startsWith(':')) continue;
|
||||
|
||||
// Parse SSE event from Claude
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
|
||||
// Skip [DONE] marker
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
|
||||
// Handle different event types from Claude
|
||||
if (event.type === 'content_block_delta' && event.delta?.text) {
|
||||
// Forward text delta
|
||||
const sseEvent = `data: ${JSON.stringify({
|
||||
type: 'content',
|
||||
text: event.delta.text,
|
||||
})}\n\n`;
|
||||
controller.enqueue(encoder.encode(sseEvent));
|
||||
} else if (event.type === 'message_stop') {
|
||||
// End of message
|
||||
const doneEvent = `data: ${JSON.stringify({ type: 'done' })}\n\n`;
|
||||
controller.enqueue(encoder.encode(doneEvent));
|
||||
} else if (event.type === 'error') {
|
||||
// Claude API error
|
||||
const errorEvent = `data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: event.error?.message || 'Unknown error',
|
||||
})}\n\n`;
|
||||
controller.enqueue(encoder.encode(errorEvent));
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse Claude event:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
const errorEvent = `data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: 'Er ging iets mis bij het streamen van de response',
|
||||
})}\n\n`;
|
||||
controller.enqueue(encoder.encode(errorEvent));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
console.log('Client disconnected from stream');
|
||||
},
|
||||
});
|
||||
|
||||
// 9. Return SSE response
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-store, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return NextResponse.json({ error: 'Ongeldige JSON payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.error('Cortex chat endpoint error:', error);
|
||||
return NextResponse.json({ error: 'Onverwachte serverfout' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
107
app/api/cortex/patients/search/route.ts
Normal file
107
app/api/cortex/patients/search/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/auth/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Swift Patient Search API
|
||||
*
|
||||
* GET /api/swift/patients/search?q=jan
|
||||
*
|
||||
* Fuzzy search for patients by name (for disambiguation).
|
||||
*/
|
||||
|
||||
// Query parameter schema
|
||||
const QuerySchema = z.object({
|
||||
q: z.string().min(1, { message: 'Zoekterm mag niet leeg zijn' }).max(100),
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Auth check
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Niet geautoriseerd. Log opnieuw in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate query parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const q = searchParams.get('q');
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Query parameter "q" is verplicht' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validation = QuerySchema.safeParse({ q });
|
||||
if (!validation.success) {
|
||||
const errorMessage = validation.error.issues
|
||||
.map((e) => e.message)
|
||||
.join(', ');
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = validation.data.q.trim();
|
||||
|
||||
// Fuzzy search on patient names
|
||||
// Search in both name_family and name_given fields
|
||||
const { data: patients, error: searchError } = await supabase
|
||||
.from('patients')
|
||||
.select('id, name_family, name_given, birth_date, identifier_bsn')
|
||||
.or(
|
||||
`name_family.ilike.%${query}%,name_given.cs.{"${query}"}`
|
||||
)
|
||||
.limit(10)
|
||||
.order('name_family', { ascending: true });
|
||||
|
||||
if (searchError) {
|
||||
console.error('Error searching patients:', searchError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Fout bij het zoeken van patiënten' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Format response
|
||||
const formattedPatients = (patients || []).map((patient) => {
|
||||
const givenName = Array.isArray(patient.name_given)
|
||||
? patient.name_given[0]
|
||||
: patient.name_given;
|
||||
const fullName = `${givenName || ''} ${patient.name_family || ''}`.trim();
|
||||
|
||||
return {
|
||||
id: patient.id,
|
||||
name: fullName || 'Onbekende naam',
|
||||
bsn: patient.identifier_bsn || undefined,
|
||||
birthDate: patient.birth_date || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
patients: formattedPatients,
|
||||
count: formattedPatients.length,
|
||||
query,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in patient search API:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Er ging iets mis bij het zoeken van patiënten. Probeer het opnieuw.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user