62 KiB
Enterprise EPD Architectuur — Intent-Driven Zorgsysteem
Type: Architectuur Referentiedocument Versie: 1.0 Datum: 2026-02-14 Doelgroep: LLM-agents, developers, architecten, CTO's Taal: Nederlands (tekst), Engels (technische termen en code) Relatie: Bouwt voort op
intent-system-architectuur-nl.md(het intent-systeem) en voegt enterprise-lagen toe
Leeswijzer
Dit document beschrijft de complete architectuur van een enterprise EPD (Elektronisch Patiëntendossier) voor middelgrote tot grote GGZ-instellingen. Het combineert twee unieke componenten — een intent-driven interface en een Knowledge Layer — met de enterprise-eisen die productie in de Nederlandse zorg vereist.
Het document is zo geschreven dat een LLM het kan lezen en er code, migraties of configuratie van kan afleiden. Elke sectie volgt: wat → waarom → hoe → trade-offs.
Het intent-systeem zelf is beschreven in het companion-document. Dit document focust op de enterprise-schil eromheen.
1. Systeemoverzicht
1.1 Twee unieke componenten + enterprise fundament
Dit systeem onderscheidt zich door twee elementen die samen een vliegwiel vormen:
┌─────────────────────────────────────────────────────────┐
│ GEBRUIKERSINTERFACE │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Traditionele │ │ Intent Command │ │
│ │ EPD UI │ │ Center (⌘K) │ │
│ │ (klikken) │ │ (typen/spreken) │ │
│ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │
│ └─────────┬─────────────────┘ │
│ │ │
│ Dezelfde API + data laag │
├────────────────────┼────────────────────────────────────┤
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 1. INTENT SYSTEEM │ │
│ │ Reflex → Orchestrator → Entity Resolution │ │
│ │ → Action System → Protocol Engine (Nudge) │ │
│ │ [zie: intent-system-architectuur-nl.md] │ │
│ └──────────────────┬──────────────────────────┘ │
│ │ voedt / wordt gevoed door │
│ ┌──────────────────┴──────────────────────────┐ │
│ │ 2. KNOWLEDGE LAYER │ │
│ │ Wet- & regelgeving │ Behandelprotocollen │ │
│ │ Kwaliteitsdocumenten │ EPD-data (eigen) │ │
│ └──────────────────┬──────────────────────────┘ │
│ │ │
├─────────────────────┼────────────────────────────────────┤
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ ENTERPRISE FUNDAMENT │ │
│ │ Multi-tenancy │ Audit trail (NEN 7513) │ │
│ │ RBAC + ABAC │ Encryptie │ FHIR export │ │
│ │ Observability │ CI/CD │ Resilience │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Component 1: Intent Systeem — Vertaalt natuurlijke taal naar gestructureerde acties. Beschreven in het companion-document. Dit is de snelle route: de zorgverlener spreekt of typt, het systeem begrijpt en bereidt voor.
Component 2: Knowledge Layer — Externe kennisbronnen die het intent-systeem en de Protocol Engine voeden. Wet- en regelgeving (Wlz, Wvggz, Wkkgz), behandelprotocollen (GGZ Standaarden, V&VN), kwaliteitsdocumenten (instellingsspecifiek) en de eigen EPD-data.
Enterprise Fundament — Alles wat nodig is om deze componenten veilig, schaalbaar en compliant te draaien in productie.
1.2 Dual Interface Principe
Het systeem heeft altijd twee gelijkwaardige ingangen naar dezelfde data:
| Interface | Wanneer | Sterkte |
|---|---|---|
| Traditionele EPD UI | Complexe workflows, overzichten, formulieren | Visueel, vertrouwd, altijd beschikbaar |
| Intent Command Center | Snelle acties, navigatie, rapportage | Snel, handsfree, context-aware |
De traditionele UI is geen fallback — het is een volwaardige interface. Het intent-systeem is geen vervanging — het is een versneller. Beide lezen en schrijven via dezelfde API-laag.
Waarom dit ertoe doet: Adoptie. Zorgverleners die gewend zijn aan klikken moeten op dag één productief zijn. Het intent-systeem groeit in gebruik naarmate vertrouwen groeit.
2. Technologie Stack
2.1 Gekozen stack en rationale
| Laag | Technologie | Waarom |
|---|---|---|
| Frontend | Next.js 14 (App Router) | Server Components voor snelle initial load, Server Actions voor forms, streaming voor AI responses |
| Styling | Tailwind CSS + shadcn/ui | Consistent design system, accessible components out of the box |
| Database | Supabase (PostgreSQL 15+) | RLS voor multi-tenancy, Realtime voor live updates, pgvector voor RAG, Edge Functions voor webhooks |
| Auth | Supabase Auth | SAML/SSO voor enterprise, MFA, session management |
| AI | Anthropic Claude API | Intent classificatie (Orchestrator), samenvattingen, chat |
| Spraak | Deepgram | Streaming speech-to-text, Nederlandse taalondersteuning |
| State | Zustand | Lightweight, geen boilerplate, splitbaar per domein |
| Validatie | Zod | Runtime type checking op API boundaries |
| Zoeken | Fuse.js (client) + pg_trgm (server) | Fuzzy matching patiëntnamen (client), trigram search (server) |
| Logging | Pino | Structured JSON logging, low overhead, PII filtering |
| Monitoring | OpenTelemetry → Grafana/Datadog | Traces, metrics, alerting |
| CI/CD | GitHub Actions | Automated testing, preview deploys, database migrations |
2.2 Wat we bewust NIET gebruiken
| Technologie | Waarom niet |
|---|---|
| Microservices | Operationele complexiteit te hoog voor team van 2-5 devs. Modular monolith geeft dezelfde scheiding met minder overhead |
| GraphQL | REST + Supabase PostgREST is voldoende. GraphQL voegt complexiteit toe zonder duidelijke winst voor dit domein |
| Redis | Supabase caching + PostgreSQL materialized views volstaan. Redis toevoegen = extra infra om te beheren |
| Kubernetes | Vercel/Supabase hosted = managed infra. K8s is overkill tot >10.000 concurrent users |
| Full FHIR server | We bouwen FHIR-compatible, niet FHIR-native. Een volledige FHIR server (HAPI) is te zwaar. We exporteren FHIR, we zijn geen FHIR store |
3. Data Architectuur
3.1 Database schema — kernmodel
Het datamodel is FHIR-inspired maar niet FHIR-native. We gebruiken PostgreSQL-native types en relaties, met FHIR export als API-laag.
-- Tenant (organisatie/instelling)
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL, -- url-friendly identifier
settings JSONB DEFAULT '{}', -- tenant-specifieke configuratie
nen_7510_certified BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Locatie binnen een instelling
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL, -- "Locatie Centrum", "Kliniek Noord"
address JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Afdeling binnen een locatie
CREATE TABLE departments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID NOT NULL REFERENCES locations(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL, -- "Afdeling Volwassenen", "Jeugd GGZ"
department_type TEXT, -- 'klinisch', 'ambulant', 'dagbehandeling'
created_at TIMESTAMPTZ DEFAULT now()
);
-- Gebruiker (zorgverlener)
CREATE TABLE users (
id UUID PRIMARY KEY REFERENCES auth.users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
department_id UUID REFERENCES departments(id),
full_name TEXT NOT NULL,
role TEXT NOT NULL, -- 'verpleegkundige', 'arts', 'psycholoog', 'admin', 'audit'
big_number TEXT, -- BIG-registratie (wettelijk vereist)
permissions TEXT[] DEFAULT '{}', -- RBAC basis permissions
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Patiënt
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
department_id UUID REFERENCES departments(id),
bsn_encrypted BYTEA, -- BSN versleuteld opgeslagen (NEN 7510)
bsn_hash TEXT, -- Hash voor lookup zonder decryptie
full_name TEXT NOT NULL,
date_of_birth DATE,
gender TEXT,
status TEXT DEFAULT 'actief', -- 'actief', 'ontslagen', 'overdracht'
admission_date DATE,
legal_status TEXT, -- 'vrijwillig', 'wvggz_zorgmachtiging', 'wvggz_crisismaatregel'
deleted_at TIMESTAMPTZ, -- soft delete
created_at TIMESTAMPTZ DEFAULT now()
);
-- Patiënt-zorgverlener toewijzing
CREATE TABLE patient_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL, -- 'hoofdbehandelaar', 'verpleegkundige', 'mentor'
active BOOLEAN DEFAULT true,
assigned_at TIMESTAMPTZ DEFAULT now(),
ended_at TIMESTAMPTZ,
UNIQUE(patient_id, user_id, role)
);
-- Rapportages (alle types)
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
patient_id UUID NOT NULL REFERENCES patients(id),
author_id UUID NOT NULL REFERENCES users(id),
report_type TEXT NOT NULL, -- 'voortgang', 'observatie', 'incident', 'medicatie',
-- 'contact', 'crisis', 'intake', 'behandeladvies',
-- 'vrije_notitie', 'verpleegkundig'
title TEXT,
content TEXT NOT NULL,
structured_data JSONB DEFAULT '{}', -- category, severity, etc.
shift_date DATE NOT NULL, -- voor 07:00 = vorige dag
version INTEGER DEFAULT 1,
previous_version_id UUID REFERENCES reports(id),
deleted_at TIMESTAMPTZ, -- soft delete, NOOIT hard delete
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Behandelplan
CREATE TABLE treatment_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
patient_id UUID NOT NULL REFERENCES patients(id),
author_id UUID NOT NULL REFERENCES users(id),
status TEXT DEFAULT 'concept', -- 'concept', 'actief', 'afgerond', 'herzien'
goals JSONB DEFAULT '[]', -- behandeldoelen
interventions JSONB DEFAULT '[]', -- interventies
evaluation_date DATE,
version INTEGER DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Afspraken / Agenda
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
patient_id UUID REFERENCES patients(id),
user_id UUID NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
appointment_type TEXT, -- 'consult', 'groepstherapie', 'mdo', 'visite'
status TEXT DEFAULT 'gepland', -- 'gepland', 'bevestigd', 'geannuleerd', 'voltooid'
location TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Toestemmingen (Wvggz / AVG)
CREATE TABLE consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
patient_id UUID NOT NULL REFERENCES patients(id),
consent_type TEXT NOT NULL, -- 'behandeling', 'datadeling', 'wvggz_zorgmachtiging'
status TEXT NOT NULL, -- 'verleend', 'ingetrokken', 'verlopen'
granted_by TEXT, -- patiënt, wettelijk vertegenwoordiger, rechter
granted_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
document_reference TEXT, -- link naar juridisch document
created_at TIMESTAMPTZ DEFAULT now()
);
3.2 Verpleegkundig rapportage — categorieën en shift-logica
// Report types die in de reports tabel worden opgeslagen
type ReportType =
| 'voortgang' // Dagelijkse voortgangsrapportage
| 'observatie' // Klinische observatie
| 'incident' // Incident of MIC-melding
| 'medicatie' // Medicatie-gerelateerd
| 'contact' // Contact met familie/instantie
| 'crisis' // Crisisinterventie
| 'intake' // Intakegesprek
| 'behandeladvies' // Advies aan behandelteam
| 'vrije_notitie' // Ongestructureerde notitie
| 'verpleegkundig'; // Verpleegkundige rapportage
// Verpleegkundige subcategorieën (in structured_data.category)
type NursingCategory = 'medicatie' | 'adl' | 'gedrag' | 'incident' | 'observatie';
// Shift-logica: rapportage vóór 07:00 hoort bij de nachtdienst van de vorige dag
function calculateShiftDate(createdAt: Date): Date {
const hour = createdAt.getHours();
if (hour < 7) {
return subDays(createdAt, 1); // date-fns
}
return createdAt;
}
// Dagdelen voor tijdlijn-weergave
type DayPart = 'nacht' | 'ochtend' | 'middag' | 'avond';
// nacht: 00:00 - 07:00
// ochtend: 07:00 - 12:00
// middag: 12:00 - 17:00
// avond: 17:00 - 00:00
3.3 Versioning en soft delete
Rapportages worden nooit hard verwijderd. Dit is een wettelijke eis (Wkkgz, NEN 7513).
// Soft delete: zet deleted_at, origineel blijft in database
async function softDeleteReport(reportId: string, userId: string) {
await supabase
.from('reports')
.update({ deleted_at: new Date().toISOString() })
.eq('id', reportId);
await logAuditEvent({
entityType: 'report',
entityId: reportId,
action: 'soft_delete',
userId,
purpose: 'user_requested_deletion',
});
}
// Versioning: nieuwe versie aanmaken, link naar vorige
async function updateReport(reportId: string, newContent: string, userId: string) {
const { data: original } = await supabase
.from('reports')
.select('*')
.eq('id', reportId)
.single();
// Maak nieuwe versie aan
const { data: newVersion } = await supabase
.from('reports')
.insert({
...original,
id: undefined, // nieuwe UUID
content: newContent,
version: original.version + 1,
previous_version_id: original.id,
updated_at: new Date().toISOString(),
})
.select()
.single();
// Soft-delete de oude versie
await supabase
.from('reports')
.update({ deleted_at: new Date().toISOString() })
.eq('id', reportId);
return newVersion;
}
4. Multi-Tenancy
4.1 Isolatiemodel: Shared Database + RLS
We kiezen voor een shared database met Row Level Security. Dit is de pragmatische keuze voor de Supabase-stack:
Waarom shared database + RLS:
- Één codebase, één database, geen per-tenant infra
- Supabase RLS is PostgreSQL-native (bewezen technologie)
- Schaalbaar tot ~100 tenants zonder schema-explosie
- Eenvoudiger te beheren dan separate databases
Waarom NIET separate databases (nu):
- Operationele complexiteit (migraties per tenant, backups per tenant)
- Supabase ondersteunt multi-database niet out of the box
- Pas relevant bij >100 tenants of bij contractuele data-isolatie eisen
4.2 RLS-implementatie
-- Elke tabel met tenant_id krijgt deze policy
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
ALTER TABLE treatment_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE consents ENABLE ROW LEVEL SECURITY;
ALTER TABLE patient_assignments ENABLE ROW LEVEL SECURITY;
-- Basis tenant-isolatie policy (template voor alle tabellen)
CREATE POLICY tenant_isolation ON patients
USING (tenant_id = (
SELECT tenant_id FROM users WHERE id = auth.uid()
))
WITH CHECK (tenant_id = (
SELECT tenant_id FROM users WHERE id = auth.uid()
));
-- Verfijnde policy: alleen toegewezen patiënten (voor klinische rollen)
CREATE POLICY assigned_patients_only ON patients
FOR SELECT
USING (
tenant_id = (SELECT tenant_id FROM users WHERE id = auth.uid())
AND (
-- Gebruiker is toegewezen aan patiënt
id IN (
SELECT patient_id FROM patient_assignments
WHERE user_id = auth.uid() AND active = true
)
-- OF gebruiker heeft 'alle_patienten' permissie (bijv. admin, dienstdoend arts)
OR EXISTS (
SELECT 1 FROM users
WHERE id = auth.uid()
AND 'alle_patienten' = ANY(permissions)
)
)
);
4.3 Tenant-context in de applicatie
// middleware.ts — Zet tenant context voor elke request
import { NextResponse } from 'next/server';
import { createMiddlewareClient } from '@/lib/supabase/middleware';
export async function middleware(request: NextRequest) {
const supabase = createMiddlewareClient(request);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Haal tenant op uit user metadata (gezet bij registratie/SSO)
const tenantId = user.user_metadata?.tenant_id;
if (!tenantId) {
return NextResponse.redirect(new URL('/onboarding', request.url));
}
// Zet als header zodat Server Components het kunnen lezen
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenantId);
return response;
}
4.4 Tenant-specifieke configuratie
// Instellingen per tenant (opgeslagen in tenants.settings JSONB)
interface TenantSettings {
// Rapportage
reportTypes: ReportType[]; // Welke report types beschikbaar zijn
nursingCategories: NursingCategory[]; // Welke categorieën
shiftChangeHour: number; // Standaard 7, configureerbaar
// Intent systeem
enabledIntents: CortexIntent[]; // Welke intents actief zijn
cortexLanguage: 'nl' | 'en'; // Taal voor intent classificatie
enableVoiceInput: boolean; // Deepgram aan/uit
// Protocol Engine / Nudge
enableNudges: boolean;
protocolSources: string[]; // Welke protocollen geladen
// Compliance
auditRetentionYears: number; // Standaard 7
requireMFA: boolean;
sessionTimeoutMinutes: number; // Standaard 15
// Branding (optioneel)
logoUrl?: string;
primaryColor?: string;
}
5. Audit Trail (NEN 7513)
5.1 Waarom dit niet-onderhandelbaar is
NEN 7513 schrijft voor dat alle toegang tot en wijziging van patiëntgegevens gelogd moet worden. Dit is geen nice-to-have — het is wettelijk verplicht voor elke zorginstelling in Nederland. Bij een audit moet je kunnen aantonen: wie heeft wat gedaan, wanneer, waar, en waarom.
5.2 Immutable audit log
-- Audit log tabel — write-once, NOOIT updaten of verwijderen
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- WIE
user_id UUID NOT NULL,
user_role TEXT NOT NULL,
user_name TEXT NOT NULL, -- Gedenormaliseerd (naam kan later wijzigen)
-- WAT
entity_type TEXT NOT NULL, -- 'patient', 'report', 'appointment', 'treatment_plan'
entity_id UUID NOT NULL,
action TEXT NOT NULL, -- 'create', 'read', 'update', 'soft_delete', 'export'
-- WANNEER
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
-- WAAR
ip_address INET,
user_agent TEXT,
session_id UUID,
source TEXT NOT NULL DEFAULT 'ui', -- 'ui', 'intent_system', 'api', 'scheduled_job'
-- WAAROM
purpose TEXT NOT NULL, -- 'klinische_zorg', 'overdracht', 'audit_review', 'onderzoek'
cortex_intent TEXT, -- Als actie via intent systeem kwam
-- INTEGRITEIT
data_hash TEXT NOT NULL, -- SHA-256 hash van wie+wat+wanneer
previous_hash TEXT, -- Keten-hash voor integriteitsverificatie
-- CONTEXT
metadata JSONB DEFAULT '{}' -- Aanvullende context (bijv. gewijzigde velden)
) PARTITION BY RANGE (timestamp);
-- Maandelijkse partities (nodig bij miljoenen events per jaar)
CREATE TABLE audit_logs_2026_01 PARTITION OF audit_logs
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE audit_logs_2026_02 PARTITION OF audit_logs
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- etc. — automatiseer met pg_partman of een cron job
-- IMMUTABILITEIT: triggers die UPDATE en DELETE blokkeren
CREATE OR REPLACE FUNCTION prevent_audit_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'Audit logs zijn immutable — wijzigen of verwijderen is niet toegestaan';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_no_update
BEFORE UPDATE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation();
CREATE TRIGGER audit_no_delete
BEFORE DELETE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation();
-- Indexes voor forensische queries
CREATE INDEX idx_audit_tenant_time ON audit_logs(tenant_id, timestamp DESC);
CREATE INDEX idx_audit_entity ON audit_logs(entity_type, entity_id, timestamp DESC);
CREATE INDEX idx_audit_user ON audit_logs(user_id, timestamp DESC);
CREATE INDEX idx_audit_patient_access ON audit_logs(entity_id)
WHERE entity_type = 'patient';
5.3 Applicatie-integratie
// lib/audit/logger.ts
import crypto from 'crypto';
import { createClient } from '@/lib/auth/server';
interface AuditEventInput {
entityType: string;
entityId: string;
action: string;
purpose: string;
cortexIntent?: string; // Welke intent triggerde dit?
metadata?: Record<string, any>;
}
export async function logAuditEvent(input: AuditEventInput) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Audit log vereist geauthenticeerde user');
// Bereken hash voor integriteitsverificatie
const hashPayload = JSON.stringify({
userId: user.id,
entityType: input.entityType,
entityId: input.entityId,
action: input.action,
timestamp: new Date().toISOString(),
});
const dataHash = crypto.createHash('sha256').update(hashPayload).digest('hex');
// Haal vorige hash op voor keten-integriteit
const { data: lastEvent } = await supabase
.from('audit_logs')
.select('data_hash')
.eq('entity_type', input.entityType)
.eq('entity_id', input.entityId)
.order('timestamp', { ascending: false })
.limit(1)
.single();
await supabase.from('audit_logs').insert({
tenant_id: user.user_metadata.tenant_id,
user_id: user.id,
user_role: user.user_metadata.role,
user_name: user.user_metadata.full_name,
entity_type: input.entityType,
entity_id: input.entityId,
action: input.action,
purpose: input.purpose,
cortex_intent: input.cortexIntent,
source: input.cortexIntent ? 'intent_system' : 'ui',
data_hash: dataHash,
previous_hash: lastEvent?.data_hash ?? null,
metadata: input.metadata ?? {},
});
}
// Gebruik in elke API route:
// Na rapport aanmaken:
await logAuditEvent({
entityType: 'report',
entityId: newReport.id,
action: 'create',
purpose: 'klinische_zorg',
cortexIntent: 'dagnotitie', // Als via intent systeem
metadata: { reportType: 'verpleegkundig', category: 'observatie' },
});
// Na patiëntdossier openen:
await logAuditEvent({
entityType: 'patient',
entityId: patientId,
action: 'read',
purpose: 'klinische_zorg',
});
5.4 Forensische queries
-- "Wie heeft patiënt X bekeken in de laatste 30 dagen?"
SELECT user_name, user_role, action, timestamp, purpose, cortex_intent
FROM audit_logs
WHERE entity_type = 'patient' AND entity_id = $1
AND timestamp > now() - interval '30 days'
ORDER BY timestamp DESC;
-- "Alle acties van gebruiker Y vandaag"
SELECT entity_type, entity_id, action, purpose, timestamp
FROM audit_logs
WHERE user_id = $1 AND timestamp::date = CURRENT_DATE
ORDER BY timestamp DESC;
-- "Alle wijzigingen aan rapport Z met versiegeschiedenis"
SELECT action, timestamp, user_name, metadata
FROM audit_logs
WHERE entity_type = 'report' AND entity_id = $1
ORDER BY timestamp ASC;
-- Anomalie detectie: "Wie heeft >50 patiëntdossiers geopend in 1 uur?"
SELECT user_id, user_name, COUNT(*) as access_count
FROM audit_logs
WHERE entity_type = 'patient' AND action = 'read'
AND timestamp > now() - interval '1 hour'
GROUP BY user_id, user_name
HAVING COUNT(*) > 50;
6. Toegangscontrole (RBAC + ABAC)
6.1 Rollenmodel
GGZ-instellingen hebben een complex rollenmodel. We implementeren een hybride RBAC + ABAC systeem.
RBAC basis (wie mag in principe wat):
| Rol | Patiënt lezen | Rapport schrijven | Behandelplan | Audit log | Systeem config |
|---|---|---|---|---|---|
verpleegkundige |
Toegewezen | Ja | Lezen | Nee | Nee |
arts |
Afdeling | Ja | Schrijven | Nee | Nee |
psycholoog |
Toegewezen | Ja | Schrijven | Nee | Nee |
hoofdbehandelaar |
Afdeling | Ja | Goedkeuren | Nee | Nee |
admin |
Alle | Nee | Nee | Lezen | Ja |
audit |
Alle (read-only) | Nee | Nee | Alle | Nee |
ABAC verfijning (context-afhankelijke regels):
// lib/access-control/policies.ts
interface AccessPolicy {
id: string;
name: string;
description: string;
conditions: PolicyCondition[];
effect: 'allow' | 'deny';
}
type PolicyCondition =
| { type: 'role'; roles: string[] }
| { type: 'assignment'; required: boolean } // Moet toegewezen zijn aan patiënt
| { type: 'time_window'; start: string; end: string } // Tijdsvenster (Wvggz)
| { type: 'consent'; consentType: string } // Toestemming vereist
| { type: 'legal_status'; statuses: string[] } // Juridische status patiënt
| { type: 'data_classification'; maxLevel: string }; // Data gevoeligheidsniveau
// Voorbeeld: Toegang tot Wvggz-patiëntdossier
const wvggzAccessPolicy: AccessPolicy = {
id: 'wvggz-patient-access',
name: 'Wvggz patiënt toegangsbeleid',
description: 'Beperkte toegang voor patiënten onder Wvggz-maatregel',
conditions: [
{ type: 'role', roles: ['arts', 'verpleegkundige', 'psycholoog'] },
{ type: 'assignment', required: true },
{ type: 'consent', consentType: 'wvggz_zorgmachtiging' },
],
effect: 'allow',
};
6.2 Policy evaluatie
// lib/access-control/evaluator.ts
export async function evaluateAccess(request: {
userId: string;
action: 'read' | 'write' | 'delete';
resource: string; // 'patient', 'report', 'treatment_plan'
resourceId: string;
tenantId: string;
}): Promise<{ allowed: boolean; reason?: string }> {
const user = await getUser(request.userId);
const policies = await getPoliciesForResource(request.resource, request.tenantId);
for (const policy of policies) {
const result = await evaluatePolicy(policy, { user, ...request });
if (policy.effect === 'deny' && result.matched) {
// Deny altijd voorrang
return { allowed: false, reason: result.reason };
}
if (policy.effect === 'allow' && result.matched) {
return { allowed: true };
}
}
// Default deny (principle of least privilege)
return { allowed: false, reason: 'Geen toepasselijk beleid gevonden' };
}
7. Encryptie & Data Bescherming
7.1 Encryptie strategie
| Laag | Methode | Wat |
|---|---|---|
| In transit | TLS 1.3 | Alle verbindingen client ↔ server ↔ database |
| At rest (database) | Supabase managed (AES-256) | Volledige database encryptie |
| At rest (velden) | Application-level AES-256-GCM | BSN, diagnose, medicatie in audit logs |
| Backup | Versleutelde backups | Point-in-time recovery met encrypted storage |
7.2 Gevoelige velden — application-level encryptie
// lib/encryption/field-encryption.ts
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
// Key management: gebruik Supabase Vault of AWS KMS in productie
const ENCRYPTION_KEY = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex');
export function encryptField(plaintext: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:ciphertext
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
export function decryptField(encrypted: string): string {
const [ivHex, authTagHex, ciphertext] = encrypted.split(':');
const decipher = crypto.createDecipheriv(
ALGORITHM, ENCRYPTION_KEY, Buffer.from(ivHex, 'hex')
);
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// BSN opslaan: hash voor lookup, encrypted voor weergave
export function storeBSN(bsn: string) {
return {
bsn_hash: crypto.createHash('sha256').update(bsn).digest('hex'),
bsn_encrypted: encryptField(bsn),
};
}
7.3 PII in AI prompts — minimalisatie
// lib/cortex/pii-filter.ts
// AI prompts krijgen NOOIT direct identificeerbare patiëntgegevens
export function sanitizeForAI(context: PatientContext): SanitizedContext {
return {
patientRef: `P-${context.patientId.slice(0, 8)}`, // Geen naam, geen BSN
age: calculateAge(context.dateOfBirth), // Leeftijd, geen geboortedatum
gender: context.gender,
activeConditions: context.conditions, // Klinisch relevant
recentReportCount: context.recentReports.length, // Alleen count
legalStatus: context.legalStatus, // Relevant voor Wvggz nudges
// NIET meesturen: naam, BSN, adres, contactgegevens
};
}
8. FHIR Interoperabiliteit
8.1 Strategie: FHIR-compatible, niet FHIR-native
We slaan data op in ons eigen PostgreSQL-model (optimaal voor onze use case) en bieden FHIR R4 export endpoints aan voor interoperabiliteit met andere systemen.
Waarom niet FHIR-native:
- FHIR resources zijn generiek — ons model is geoptimaliseerd voor GGZ
- Volledige FHIR server (HAPI) is Java/heavy — past niet bij onze stack
- Nederlandse EPD-markt verwacht FHIR export, niet FHIR storage
8.2 FHIR export endpoints
// app/api/fhir/Patient/[id]/route.ts
// Vertaalt ons interne model naar FHIR R4 Patient resource
export async function GET(req: Request, { params }: { params: { id: string } }) {
const supabase = await createClient();
const { data: patient } = await supabase
.from('patients')
.select('*')
.eq('id', params.id)
.single();
if (!patient) return Response.json({ error: 'Patient not found' }, { status: 404 });
// Log FHIR export in audit trail
await logAuditEvent({
entityType: 'patient',
entityId: params.id,
action: 'export',
purpose: 'fhir_interoperability',
});
// Vertaal naar FHIR R4
const fhirPatient = {
resourceType: 'Patient',
id: patient.id,
identifier: patient.bsn_hash ? [{
system: 'http://fhir.nl/NamingSystem/bsn',
value: patient.bsn_hash, // Hash, niet de echte BSN
}] : [],
name: [{
text: patient.full_name,
family: patient.full_name.split(' ').pop(),
given: patient.full_name.split(' ').slice(0, -1),
}],
gender: mapGender(patient.gender),
birthDate: patient.date_of_birth,
active: patient.status === 'actief',
};
return Response.json(fhirPatient, {
headers: { 'Content-Type': 'application/fhir+json' },
});
}
// FHIR Bundle export (alle data van een patiënt)
// app/api/fhir/Patient/[id]/$everything/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const patient = await getFHIRPatient(params.id);
const observations = await getFHIRObservations(params.id);
const conditions = await getFHIRConditions(params.id);
return Response.json({
resourceType: 'Bundle',
type: 'searchset',
total: 1 + observations.length + conditions.length,
entry: [
{ resource: patient },
...observations.map(o => ({ resource: o })),
...conditions.map(c => ({ resource: c })),
],
});
}
8.3 CDS Hooks integratie
// app/api/cds-hooks/patient-view/route.ts
// Wordt aangeroepen wanneer een clinicus een patiëntdossier opent
// Retourneert nudge-achtige suggesties in CDS Hooks formaat
export async function POST(req: Request) {
const body = await req.json();
const patientId = body.context?.patientId;
// Evalueer protocol regels (dezelfde logic als Nudge systeem)
const nudges = await evaluateProtocolRules(patientId);
// Vertaal naar CDS Hooks cards
const cards = nudges.map(nudge => ({
uuid: nudge.id,
summary: nudge.message,
indicator: nudge.priority === 'P1' ? 'critical' : 'info',
source: {
label: nudge.source, // 'GGZ Standaard Depressie'
url: nudge.sourceUrl,
},
suggestions: nudge.suggestedAction ? [{
label: nudge.suggestedAction.label,
actions: [{
type: 'create',
description: nudge.suggestedAction.description,
resource: nudge.suggestedAction.fhirResource,
}],
}] : [],
}));
return Response.json({ cards });
}
9. Observability & Monitoring
9.1 Drie pijlers
| Pijler | Tool | Wat |
|---|---|---|
| Logs | Pino → stdout → log aggregator | Gestructureerde JSON logs, PII-gefilterd |
| Metrics | OpenTelemetry → Grafana/Datadog | Latency, error rate, classificatie-snelheid |
| Traces | OpenTelemetry | Request flow door de hele pipeline |
9.2 Cortex-specifieke metrics
// lib/observability/cortex-metrics.ts
// Metrics die we tracken per intent classificatie:
interface ClassificationMetrics {
totalClassifications: Counter;
reflexHits: Counter; // Opgelost door Reflex (snel)
orchestratorFallbacks: Counter; // Doorverwezen naar LLM (langzaam)
classificationLatency: Histogram; // ms per classificatie
confidenceDistribution: Histogram; // Verdeling van confidence scores
intentDistribution: Counter; // Welke intents het meest voorkomen
entityExtractionErrors: Counter; // Mislukte entity extractie
nudgeSuggestions: Counter; // Aantal nudge suggesties gegeven
nudgeAccepted: Counter; // Aantal nudges geaccepteerd door gebruiker
}
// Dashboard alerts:
// - Reflex hit rate < 60% → patronen moeten geüpdatet worden
// - Orchestrator latency p95 > 3s → AI provider probleem
// - Entity extraction error rate > 5% → extractie regels reviewen
// - Nudge acceptance rate < 10% → nudges zijn niet relevant genoeg
9.3 Health checks
// app/api/health/route.ts
export async function GET() {
const checks = await Promise.allSettled([
checkDatabase(),
checkAnthropicAPI(),
checkDeepgram(),
checkSupabaseAuth(),
]);
const status = checks.every(c => c.status === 'fulfilled') ? 'healthy' : 'degraded';
return Response.json({
status,
timestamp: new Date().toISOString(),
checks: {
database: checks[0].status === 'fulfilled' ? 'up' : 'down',
ai: checks[1].status === 'fulfilled' ? 'up' : 'down',
speech: checks[2].status === 'fulfilled' ? 'up' : 'down',
auth: checks[3].status === 'fulfilled' ? 'up' : 'down',
},
// Graceful degradation status
capabilities: {
intentClassification: checks[1].status === 'fulfilled' ? 'full' : 'reflex_only',
voiceInput: checks[2].status === 'fulfilled' ? 'available' : 'unavailable',
reporting: checks[0].status === 'fulfilled' ? 'available' : 'unavailable',
},
});
}
10. Resilience & Graceful Degradation
10.1 Drie degradatieniveaus
Het systeem moet altijd blijven functioneren, ook als externe services uitvallen.
Niveau 0: VOLLEDIG OPERATIONEEL
├── Intent systeem: Reflex + Orchestrator (LLM)
├── Spraak: Deepgram beschikbaar
├── Nudges: Protocol Engine actief
└── Alle functies beschikbaar
Niveau 1: VERMINDERD (AI niet beschikbaar)
├── Intent systeem: Alleen Reflex (lokale patronen)
│ → ~70% van intents wordt nog herkend
│ → Complexe/ambigue input krijgt "niet begrepen" melding
├── Spraak: Deepgram beschikbaar (of ook uit → alleen tekst)
├── Nudges: Alleen statische regels (geen AI-suggesties)
└── Traditionele UI: Volledig beschikbaar ✓
Niveau 2: MINIMAAL (database traag/beperkt)
├── Intent systeem: Uit
├── Traditionele UI: Read-only modus
├── Nieuwe rapportages: Lokaal gebufferd, sync later
└── Banner: "Beperkte beschikbaarheid — sommige functies zijn tijdelijk niet beschikbaar"
10.2 Circuit breaker voor AI providers
// lib/resilience/circuit-breaker.ts
interface CircuitBreakerState {
status: 'closed' | 'open' | 'half-open';
failureCount: number;
lastFailure: Date | null;
nextAttempt: Date | null;
}
const AI_CIRCUIT_BREAKER: CircuitBreakerState = {
status: 'closed',
failureCount: 0,
lastFailure: null,
nextAttempt: null,
};
const MAX_FAILURES = 3;
const RECOVERY_TIME_MS = 30_000; // 30 seconden
export async function callWithCircuitBreaker<T>(
fn: () => Promise<T>,
fallback: () => T,
): Promise<T> {
if (AI_CIRCUIT_BREAKER.status === 'open') {
if (AI_CIRCUIT_BREAKER.nextAttempt && new Date() > AI_CIRCUIT_BREAKER.nextAttempt) {
AI_CIRCUIT_BREAKER.status = 'half-open';
} else {
return fallback(); // Gebruik fallback (Reflex-only)
}
}
try {
const result = await fn();
if (AI_CIRCUIT_BREAKER.status === 'half-open') {
AI_CIRCUIT_BREAKER.status = 'closed';
AI_CIRCUIT_BREAKER.failureCount = 0;
}
return result;
} catch (error) {
AI_CIRCUIT_BREAKER.failureCount++;
AI_CIRCUIT_BREAKER.lastFailure = new Date();
if (AI_CIRCUIT_BREAKER.failureCount >= MAX_FAILURES) {
AI_CIRCUIT_BREAKER.status = 'open';
AI_CIRCUIT_BREAKER.nextAttempt = new Date(Date.now() + RECOVERY_TIME_MS);
}
return fallback();
}
}
11. Nederlandse Zorgwetgeving — Compliance Matrix
11.1 Relevante wetgeving en impact op architectuur
| Wet | Wat het regelt | Impact op het systeem |
|---|---|---|
| Wlz (Wet langdurige zorg) | Recht op langdurige zorg | Moet zorgindicaties kunnen registreren en rapporteren |
| Wvggz (Wet verplichte GGZ) | Verplichte/onvrijwillige zorg | Legal status tracking, extra audit requirements, toestemmingsregistratie |
| Wkkgz (Wet kwaliteit klachten geschillen zorg) | Kwaliteit en veiligheid | Incident-rapportage (MIC), klachtenregistratie, dossierplicht |
| Wmcz (Wet medezeggenschap cliënten zorginstellingen) | Cliëntenparticipatie | Niet direct, maar relevant voor governance |
| AVG/GDPR | Persoonsgegevens bescherming | Toestemming, recht op inzage, dataportabiliteit, DPIA |
| NEN 7510 | Informatiebeveiliging zorg | RBAC, encryptie, access control, incident management |
| NEN 7512 | Veilige gegevensuitwisseling | TLS, authenticatie bij data-export, FHIR security |
| NEN 7513 | Logging van zorggebeurtenissen | Immutable audit trail, 7 jaar bewaarplicht |
| BIG-wet | Registratie zorgverleners | BIG-nummer opslaan bij gebruikers, verifiëren |
11.2 Wvggz-specifieke functionaliteit
De Wvggz verdient extra aandacht omdat het direct impact heeft op het datamodel en de toegangscontrole:
// Juridische statussen die het systeem moet ondersteunen
type LegalStatus =
| 'vrijwillig' // Vrijwillige opname
| 'wvggz_zorgmachtiging' // Rechterlijke zorgmachtiging
| 'wvggz_crisismaatregel' // Burgemeester-crisismaatregel
| 'wvggz_machtiging_voortgezet' // Voortgezette crisismaatregel
| 'wzd_rechterlijke_machtiging'; // Wet zorg en dwang (andere doelgroep)
// Impact op toegang:
// - Wvggz-patiënten: extra logging bij elke dossierinzage
// - Wvggz-patiënten: toestemmingsregistratie verplicht
// - Wvggz-patiënten: dwangmaatregelen moeten geregistreerd worden
// - Wvggz-patiënten: periodieke evaluatie door geneesheer-directeur
// Impact op Nudge systeem:
// Protocol Engine regel voorbeeld:
// "Als patient.legalStatus = 'wvggz_zorgmachtiging'
// EN laatste evaluatie > 6 maanden geleden
// → Nudge: 'Evaluatie zorgmachtiging is verlopen, plan evaluatie in'"
12. Knowledge Layer — Detail
12.1 Vier kennisbronnen
De Knowledge Layer is de tweede unieke component van het systeem. Het voedt de Protocol Engine (Nudge) met actuele, verifieerbare kennis.
┌─────────────────────────────────────────────────────────────┐
│ KNOWLEDGE LAYER │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 1. WETGEVING │ │ 2. PROTOCOLLEN│ │ 3. KWALITEITS│ │
│ │ │ │ │ │ DOCUMENTEN │ │
│ │ Wlz, Wvggz │ │ GGZ Std. │ │ │ │
│ │ Wkkgz, NEN │ │ V&VN, FMS │ │ Instelling- │ │
│ │ 7510/12/13 │ │ NHG, CBO │ │ specifiek │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────┬────────┴──────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ PROTOCOL RULES ENGINE │ │
│ │ Gevalideerde regels met bron-verwijzing │ │
│ │ (completedAction, rules) → suggestions[] │ │
│ └──────────────────┬───────────────────────────┘ │
│ │ │
│ ┌──────────────────┴───────────────────────────┐ │
│ │ 4. EPD DATA (eigen data) │ │
│ │ Patiëntgegevens, rapportages, behandelplannen│ │
│ │ → Data-driven conditions voor nudges │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
12.2 Protocol Rule structuur
// lib/knowledge/protocol-rule.ts
interface ProtocolRule {
id: string;
name: string;
description: string;
// Bron
source: {
type: 'wetgeving' | 'richtlijn' | 'instelling' | 'epd_data' | 'ai_suggested';
name: string; // "GGZ Standaard Depressie v3.0"
section?: string; // "Hoofdstuk 4.2 - Medicatiebewaking"
url?: string; // Link naar bron
version: string;
};
// Validatie
validatedBy: string; // userId van validator
validatedAt: Date;
status: 'concept' | 'gevalideerd' | 'actief' | 'verlopen';
// Trigger
trigger: {
afterIntent?: CortexIntent[]; // Na welke intents evalueren
afterAction?: string[]; // Na welke acties
schedule?: string; // Cron voor periodieke checks
};
// Conditie
condition: RuleCondition;
// Actie
suggestion: {
message: string; // "Overweeg lithiumspiegel te controleren"
priority: 'P1' | 'P2' | 'P3';
suggestedIntent?: CortexIntent; // Welke intent als vervolgactie
evidence: string; // "Volgens GGZ Standaard Bipolair §4.3"
};
}
// Condities kunnen statisch of data-driven zijn
type RuleCondition =
| { type: 'static'; check: (context: RuleContext) => boolean }
| { type: 'query'; table: string; filter: Record<string, any>; expect: 'exists' | 'not_exists' | 'count_gt' | 'count_lt'; threshold?: number };
// Voorbeeld: data-driven conditie
const medicatieReviewRule: ProtocolRule = {
id: 'med-review-quarterly',
name: 'Kwartaal medicatiereview',
description: 'Herinnering voor medicatiereview elke 3 maanden',
source: {
type: 'richtlijn',
name: 'GGZ Standaard Psychose',
section: '6.3 Medicatiebewaking',
version: '2024.1',
},
validatedBy: 'user-uuid-arts',
validatedAt: new Date('2026-01-15'),
status: 'actief',
trigger: { afterIntent: ['dagnotitie', 'overdracht'] },
condition: {
type: 'query',
table: 'reports',
filter: {
patient_id: '$activePatient',
report_type: 'medicatie',
'structured_data->category': 'review',
},
expect: 'not_exists', // Geen medicatiereview gevonden
// in de laatste 90 dagen (impliciet via created_at filter)
},
suggestion: {
message: 'Laatste medicatiereview is >3 maanden geleden. Overweeg een review in te plannen.',
priority: 'P2',
suggestedIntent: 'create_appointment',
evidence: 'GGZ Standaard Psychose §6.3: "Evalueer medicatie minimaal elke 3 maanden"',
},
};
12.3 RAG voor protocolkennis
Voor het ontsluiten van omvangrijke protocolteksten (GGZ Standaarden zijn honderden pagina's) gebruiken we RAG (Retrieval-Augmented Generation):
// lib/knowledge/rag-pipeline.ts
// Stap 1: Indexeer protocoldocumenten (eenmalig of bij update)
// Gebruik pgvector in Supabase voor vector storage
// Tabel:
// protocol_chunks (id, source_id, chunk_text, embedding vector(1536), metadata jsonb)
// Stap 2: Bij een nudge-evaluatie, zoek relevante chunks
async function findRelevantProtocolChunks(query: string, topK: number = 5) {
const embedding = await generateEmbedding(query); // OpenAI of Anthropic
const { data } = await supabase.rpc('match_protocol_chunks', {
query_embedding: embedding,
match_threshold: 0.7,
match_count: topK,
});
return data; // Relevante protocolfragmenten
}
// Stap 3: AI suggereert candidate rules op basis van protocol
// Mens (arts/kwaliteitsmedewerker) valideert en activeert
async function suggestRulesFromProtocol(protocolId: string) {
const chunks = await getProtocolChunks(protocolId);
const suggestions = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20250514',
system: `Je bent een klinische informaticus. Analyseer dit protocol en stel
concrete nudge-regels voor die een EPD-systeem kan evalueren.
Elke regel moet: een trigger, een conditie en een suggestie bevatten.
Verwijs naar het exacte hoofdstuk/paragraaf.`,
messages: [{ role: 'user', content: chunks.map(c => c.text).join('\n\n') }],
});
// Parse suggestions → ProtocolRule[] met status 'ai_suggested'
// Toon in admin UI voor validatie door arts
return parseSuggestedRules(suggestions, { status: 'ai_suggested' });
}
Trade-off: Rules vs RAG:
- Rules (handmatig gevalideerd): Betrouwbaar, traceerbaar, maar arbeidsintensief
- RAG (AI-gesuggereerd): Schaalbaar, maar vereist menselijke validatie
- Onze keuze: RAG voor candidate generation, mens voor validatie. Nooit automatisch actief.
13. API Structuur
13.1 Route-indeling
app/api/
├── auth/ # Authenticatie (Supabase Auth wrappers)
│ ├── login/route.ts
│ ├── logout/route.ts
│ └── callback/route.ts # SSO callback
│
├── patients/ # Patiënt CRUD
│ ├── route.ts # GET (lijst), POST (aanmaken)
│ ├── [id]/route.ts # GET, PATCH (details)
│ └── [id]/assignments/route.ts
│
├── reports/ # Rapportage CRUD
│ ├── route.ts # GET (lijst met filters), POST
│ └── [id]/route.ts # GET, PATCH, DELETE (soft)
│
├── treatment-plans/ # Behandelplannen
│ ├── route.ts
│ └── [id]/route.ts
│
├── appointments/ # Agenda
│ ├── route.ts
│ └── [id]/route.ts
│
├── cortex/ # Intent systeem
│ ├── classify/route.ts # POST: tekst → intent + entities
│ ├── chat/route.ts # POST: streaming chat (SSE)
│ ├── context/route.ts # GET: huidige context
│ └── nudge/route.ts # GET: evalueer nudges voor context
│
├── overdracht/ # Overdracht / handover
│ ├── route.ts # GET: overzicht
│ ├── [patientId]/route.ts # GET: patient detail
│ └── generate/route.ts # POST: AI samenvatting genereren
│
├── fhir/ # FHIR R4 export
│ ├── Patient/[id]/route.ts
│ ├── Observation/route.ts
│ └── Bundle/route.ts
│
├── cds-hooks/ # CDS Hooks endpoints
│ └── patient-view/route.ts
│
├── audit/ # Audit log queries (admin only)
│ ├── route.ts # GET: zoeken in logs
│ └── export/route.ts # GET: CSV/JSON export
│
├── admin/ # Systeembeheer
│ ├── tenants/route.ts
│ ├── users/route.ts
│ └── settings/route.ts
│
├── health/route.ts # Health check endpoint
└── deepgram/route.ts # Speech-to-text proxy
13.2 API route patroon
// Standaard patroon voor elke API route
import { createClient } from '@/lib/auth/server';
import { logAuditEvent } from '@/lib/audit/logger';
import { evaluateAccess } from '@/lib/access-control/evaluator';
import { z } from 'zod';
// 1. Zod schema voor validatie
const CreateReportSchema = z.object({
patientId: z.string().uuid(),
reportType: z.enum(['voortgang', 'observatie', 'incident', /* ... */]),
content: z.string().min(1).max(10000),
structuredData: z.object({
category: z.enum(['medicatie', 'adl', 'gedrag', 'incident', 'observatie']).optional(),
}).optional(),
});
export async function POST(req: Request) {
// 2. Auth check
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return Response.json({ error: 'Niet geauthenticeerd' }, { status: 401 });
// 3. Input validatie
const body = await req.json();
const parsed = CreateReportSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: 'Ongeldige invoer', details: parsed.error.issues }, { status: 400 });
}
// 4. Toegangscontrole (RBAC + ABAC)
const access = await evaluateAccess({
userId: user.id,
action: 'write',
resource: 'report',
resourceId: parsed.data.patientId,
tenantId: user.user_metadata.tenant_id,
});
if (!access.allowed) {
return Response.json({ error: 'Geen toegang', reason: access.reason }, { status: 403 });
}
// 5. Business logic
const shiftDate = calculateShiftDate(new Date());
const { data: report, error } = await supabase
.from('reports')
.insert({
tenant_id: user.user_metadata.tenant_id,
patient_id: parsed.data.patientId,
author_id: user.id,
report_type: parsed.data.reportType,
content: parsed.data.content,
structured_data: parsed.data.structuredData ?? {},
shift_date: shiftDate,
})
.select()
.single();
if (error) return Response.json({ error: 'Fout bij opslaan' }, { status: 500 });
// 6. Audit log
await logAuditEvent({
entityType: 'report',
entityId: report.id,
action: 'create',
purpose: 'klinische_zorg',
metadata: { reportType: parsed.data.reportType },
});
// 7. Response
return Response.json(report, { status: 201 });
}
14. EPD Modules
14.1 Module-overzicht
app/epd/
├── dashboard/ # Hoofddashboard + Cortex command center
├── patients/ # Patiëntenoverzicht en -beheer
│ └── [id]/ # Individueel patiëntdossier
│ ├── overzicht/ # Samenvattingsweergave
│ ├── rapportages/ # Rapportagegeschiedenis (tijdlijn)
│ ├── behandelplan/ # Behandelplan + doelen
│ ├── medicatie/ # Medicatieoverzicht
│ └── agenda/ # Patiënt-specifieke afspraken
├── rapportage/ # Rapportage-invoer workspace
│ └── nieuw/ # Nieuw rapport formulier
├── overdracht/ # Overdracht overzicht (dienstwisseling)
├── agenda/ # Team/persoonlijke agenda (FullCalendar)
├── clients/ # Cliëntbeheer (admin)
└── instellingen/ # Systeeminstellingen (admin)
├── gebruikers/ # Gebruikersbeheer
├── afdelingen/ # Afdelingsconfiguratie
├── protocollen/ # Protocol/nudge regels beheer
└── audit/ # Audit log viewer
14.2 Intent → Module mapping
Het intent-systeem navigeert naar of pre-fillt modules:
| Intent | Module | Actie |
|---|---|---|
dagnotitie |
rapportage/nieuw | Pre-fill formulier met patiënt, categorie, inhoud |
zoeken |
patients | Filter patiëntenlijst op zoekterm |
overdracht |
overdracht | Open overdracht met patiëntfocus |
agenda_query |
agenda | Filter op datum/patiënt |
create_appointment |
agenda | Open nieuw-afspraak formulier |
cancel_appointment |
agenda | Open annulerings-dialog |
reschedule_appointment |
agenda | Open verplaats-dialog |
15. Bouwvolgorde
15.1 Fasering
FASE 1: FUNDAMENT (maand 1-2)
├── Database schema + migraties
├── Auth + multi-tenancy (RLS)
├── Basis UI: patiëntenlijst, dossier, formulieren
├── Rapportage CRUD (het meest gebruikte scherm)
├── Audit trail (NEN 7513) — vanaf dag 1 meebouwen
└── Deliverable: werkend EPD zonder intent-systeem
FASE 2: INTENT SYSTEEM MVP (maand 2-3)
├── Intent Registry (database-driven)
├── Reflex classifier (lokale patronen)
├── Command input overlay (⌘K)
├── 3-4 intents: dagnotitie, zoeken, overdracht, agenda_query
├── Pre-fill pattern: intent → open formulier met data
└── Deliverable: EPD + intent-laag voor routinetaken
FASE 3: INTELLIGENCE (maand 3-4)
├── Orchestrator (LLM classificatie)
├── Entity resolution met fuzzy matching
├── Spraak-input (Deepgram)
├── Overdracht met AI-samenvatting
├── Nudge systeem v1 (statische regels)
└── Deliverable: slimme EPD met AI-assist
FASE 4: KNOWLEDGE LAYER (maand 4-5)
├── Protocol Engine met gevalideerde regels
├── RAG pipeline voor protocoldocumenten
├── Eerste protocollen inladen (GGZ Standaarden)
├── Admin UI voor regelbeheer
└── Deliverable: evidence-based nudges
FASE 5: COMPLIANCE & SCHAAL (maand 5-6)
├── FHIR R4 export endpoints
├── CDS Hooks integratie
├── Encryptie verfijning (field-level)
├── ABAC policies (Wvggz-specifiek)
├── Observability (Pino, OpenTelemetry)
├── Performance optimalisatie
└── Deliverable: compliance-ready systeem
DOORLOPEND:
├── Testing (unit, integration, e2e)
├── CI/CD pipeline
├── Documentatie
└── Gebruikerstesten met zorgverleners
16. Beslissingenlog
| # | Beslissing | Alternatieven overwogen | Rationale |
|---|---|---|---|
| D1 | Modular monolith (geen microservices) | Microservices, serverless | Team <5 devs, operationele eenvoud, Supabase is al managed |
| D2 | Shared database + RLS (geen separate DBs) | Separate databases per tenant | Supabase-native, schaalbaar tot ~100 tenants, eenvoudiger migraties |
| D3 | FHIR-compatible export (geen FHIR-native) | Full FHIR server (HAPI) | Te zwaar voor onze stack, Nederlandse markt verwacht export niet storage |
| D4 | Hybrid RBAC + ABAC | Alleen RBAC, alleen ABAC | RBAC te simpel voor Wvggz, ABAC alleen te complex. Hybride geeft flexibiliteit |
| D5 | Application-level field encryptie | Database-level transparent encryption | Meer controle over wat versleuteld is, nodig voor BSN en diagnoses in audit logs |
| D6 | Pino voor logging (geen Winston) | Winston, Bunyan, console.log | Laagste overhead, native JSON, PII filtering pipeline |
| D7 | pgvector voor RAG (geen Pinecone) | Pinecone, Weaviate, Chroma | Supabase-native, geen extra service, voldoende voor protocol-volumes |
| D8 | Audit trail vanaf dag 1 | Later toevoegen | Retrofit is exponentieel duurder, NEN 7513 is wettelijk verplicht |
| D9 | Dual interface (traditioneel + intent) | Alleen intent, alleen traditioneel | Adoptie vereist vertrouwde UI, intent is versneller niet vervanging |
| D10 | Immutable audit logs met hash chain | Append-only zonder hash | Hash chain geeft cryptografisch bewijs van integriteit (NEN 7513) |
Appendix A: Relatie met het Intent Systeem Document
Dit document beschrijft de enterprise-schil. Het intent-systeem zelf — met de vijf bouwblokken (Intent Registry, Classification Pipeline, Entity Resolution, Action System, Protocol Engine) — is volledig beschreven in intent-system-architectuur-nl.md.
De twee documenten samen vormen de complete architectuur:
intent-system-architectuur-nl.md enterprise-epd-architectuur.md
┌────────────────────────────┐ ┌──────────────────────────────┐
│ Hoe het intent systeem │ │ Hoe het enterprise EPD │
│ intern werkt: │ │ eromheen werkt: │
│ │ │ │
│ • Intent Registry │◄────►│ • Multi-tenancy │
│ • Classification Pipeline │ │ • Audit trail (NEN 7513) │
│ • Entity Resolution │ │ • Toegangscontrole (RBAC+ABAC)│
│ • Action System │ │ • Encryptie & data bescherming│
│ • Protocol Engine (Nudge) │ │ • FHIR interoperabiliteit │
│ │ │ • Knowledge Layer │
│ │ │ • Observability │
│ │ │ • Resilience │
│ │ │ • Zorgwetgeving compliance │
│ │ │ • EPD modules & UI │
└────────────────────────────┘ └──────────────────────────────┘
Appendix B: Technologie Referenties
| Technologie | Documentatie | Versie |
|---|---|---|
| Next.js | nextjs.org/docs | 14 (App Router) |
| Supabase | supabase.com/docs | Latest |
| PostgreSQL | postgresql.org/docs | 15+ |
| Anthropic Claude | docs.anthropic.com | claude-sonnet-4-5 / claude-opus-4 |
| Deepgram | developers.deepgram.com | Nova-2 |
| Tailwind CSS | tailwindcss.com | 3.x |
| shadcn/ui | ui.shadcn.com | Latest |
| Zod | zod.dev | 3.x |
| Zustand | zustand-demo.pmnd.rs | 4.x |
| Pino | getpino.io | 8.x |
| OpenTelemetry | opentelemetry.io | 1.x |
| FHIR R4 | hl7.org/fhir/R4 | 4.0.1 |
| CDS Hooks | cds-hooks.org | 2.0 |
Appendix C: Standaarden Referenties
| Standaard | Organisatie | Relevant voor |
|---|---|---|
| NEN 7510 | NEN | Informatiebeveiliging (→ RBAC, encryptie, access control) |
| NEN 7512 | NEN | Veilige gegevensuitwisseling (→ FHIR, TLS) |
| NEN 7513 | NEN | Logging (→ audit trail) |
| FHIR R4 | HL7 | Interoperabiliteit (→ export endpoints) |
| CDS Hooks | HL7 | Clinical decision support (→ nudge/protocol engine) |
| GGZ Standaarden | Akwa GGZ | Behandelprotocollen (→ Knowledge Layer) |
| V&VN richtlijnen | V&VN | Verpleegkundige protocollen (→ Knowledge Layer) |
| AVG/GDPR | EU | Privacy (→ encryptie, toestemming, dataportabiliteit) |