1620 lines
62 KiB
Markdown
1620 lines
62 KiB
Markdown
# 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<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
|
|
|
|
```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<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:
|
|
|
|
```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<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):
|
|
|
|
```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) |
|