# 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. ```sql -- 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 ```typescript // 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). ```typescript // 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 ```sql -- 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 ```typescript // 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 ```typescript // 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 ```sql -- 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 ```typescript // 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; } 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 ```sql -- "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): ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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( fn: () => Promise, fallback: () => T, ): Promise { 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: ```typescript // 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 ```typescript // 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; 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): ```typescript // 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 ```typescript // 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) |