Files
triqura-ecd/docs/architecture/enterprise-epd-architectuur.md
2026-07-09 23:16:28 +02:00

62 KiB

Enterprise EPD Architectuur — Intent-Driven Zorgsysteem

Type: Architectuur Referentiedocument Versie: 1.0 Datum: 2026-02-14 Doelgroep: LLM-agents, developers, architecten, CTO's Taal: Nederlands (tekst), Engels (technische termen en code) Relatie: Bouwt voort op intent-system-architectuur-nl.md (het intent-systeem) en voegt enterprise-lagen toe


Leeswijzer

Dit document beschrijft de complete architectuur van een enterprise EPD (Elektronisch Patiëntendossier) voor middelgrote tot grote GGZ-instellingen. Het combineert twee unieke componenten — een intent-driven interface en een Knowledge Layer — met de enterprise-eisen die productie in de Nederlandse zorg vereist.

Het document is zo geschreven dat een LLM het kan lezen en er code, migraties of configuratie van kan afleiden. Elke sectie volgt: watwaaromhoetrade-offs.

Het intent-systeem zelf is beschreven in het companion-document. Dit document focust op de enterprise-schil eromheen.


1. Systeemoverzicht

1.1 Twee unieke componenten + enterprise fundament

Dit systeem onderscheidt zich door twee elementen die samen een vliegwiel vormen:

┌─────────────────────────────────────────────────────────┐
│                  GEBRUIKERSINTERFACE                     │
│                                                          │
│   ┌──────────────┐        ┌──────────────────────┐      │
│   │  Traditionele │        │  Intent Command      │      │
│   │  EPD UI       │        │  Center (⌘K)         │      │
│   │  (klikken)    │        │  (typen/spreken)     │      │
│   └──────┬───────┘        └──────────┬───────────┘      │
│          │                           │                   │
│          └─────────┬─────────────────┘                   │
│                    │                                     │
│          Dezelfde API + data laag                        │
├────────────────────┼────────────────────────────────────┤
│                    ▼                                     │
│  ┌─────────────────────────────────────────────┐        │
│  │        1. INTENT SYSTEEM                     │        │
│  │  Reflex → Orchestrator → Entity Resolution   │        │
│  │  → Action System → Protocol Engine (Nudge)   │        │
│  │  [zie: intent-system-architectuur-nl.md]      │        │
│  └──────────────────┬──────────────────────────┘        │
│                     │ voedt / wordt gevoed door          │
│  ┌──────────────────┴──────────────────────────┐        │
│  │        2. KNOWLEDGE LAYER                    │        │
│  │  Wet- & regelgeving │ Behandelprotocollen    │        │
│  │  Kwaliteitsdocumenten │ EPD-data (eigen)     │        │
│  └──────────────────┬──────────────────────────┘        │
│                     │                                    │
├─────────────────────┼────────────────────────────────────┤
│                     ▼                                     │
│  ┌─────────────────────────────────────────────┐        │
│  │        ENTERPRISE FUNDAMENT                  │        │
│  │  Multi-tenancy │ Audit trail (NEN 7513)      │        │
│  │  RBAC + ABAC │ Encryptie │ FHIR export       │        │
│  │  Observability │ CI/CD │ Resilience           │        │
│  └─────────────────────────────────────────────┘        │
└─────────────────────────────────────────────────────────┘

Component 1: Intent Systeem — Vertaalt natuurlijke taal naar gestructureerde acties. Beschreven in het companion-document. Dit is de snelle route: de zorgverlener spreekt of typt, het systeem begrijpt en bereidt voor.

Component 2: Knowledge Layer — Externe kennisbronnen die het intent-systeem en de Protocol Engine voeden. Wet- en regelgeving (Wlz, Wvggz, Wkkgz), behandelprotocollen (GGZ Standaarden, V&VN), kwaliteitsdocumenten (instellingsspecifiek) en de eigen EPD-data.

Enterprise Fundament — Alles wat nodig is om deze componenten veilig, schaalbaar en compliant te draaien in productie.

1.2 Dual Interface Principe

Het systeem heeft altijd twee gelijkwaardige ingangen naar dezelfde data:

Interface Wanneer Sterkte
Traditionele EPD UI Complexe workflows, overzichten, formulieren Visueel, vertrouwd, altijd beschikbaar
Intent Command Center Snelle acties, navigatie, rapportage Snel, handsfree, context-aware

De traditionele UI is geen fallback — het is een volwaardige interface. Het intent-systeem is geen vervanging — het is een versneller. Beide lezen en schrijven via dezelfde API-laag.

Waarom dit ertoe doet: Adoptie. Zorgverleners die gewend zijn aan klikken moeten op dag één productief zijn. Het intent-systeem groeit in gebruik naarmate vertrouwen groeit.


2. Technologie Stack

2.1 Gekozen stack en rationale

Laag Technologie Waarom
Frontend Next.js 14 (App Router) Server Components voor snelle initial load, Server Actions voor forms, streaming voor AI responses
Styling Tailwind CSS + shadcn/ui Consistent design system, accessible components out of the box
Database Supabase (PostgreSQL 15+) RLS voor multi-tenancy, Realtime voor live updates, pgvector voor RAG, Edge Functions voor webhooks
Auth Supabase Auth SAML/SSO voor enterprise, MFA, session management
AI Anthropic Claude API Intent classificatie (Orchestrator), samenvattingen, chat
Spraak Deepgram Streaming speech-to-text, Nederlandse taalondersteuning
State Zustand Lightweight, geen boilerplate, splitbaar per domein
Validatie Zod Runtime type checking op API boundaries
Zoeken Fuse.js (client) + pg_trgm (server) Fuzzy matching patiëntnamen (client), trigram search (server)
Logging Pino Structured JSON logging, low overhead, PII filtering
Monitoring OpenTelemetry → Grafana/Datadog Traces, metrics, alerting
CI/CD GitHub Actions Automated testing, preview deploys, database migrations

2.2 Wat we bewust NIET gebruiken

Technologie Waarom niet
Microservices Operationele complexiteit te hoog voor team van 2-5 devs. Modular monolith geeft dezelfde scheiding met minder overhead
GraphQL REST + Supabase PostgREST is voldoende. GraphQL voegt complexiteit toe zonder duidelijke winst voor dit domein
Redis Supabase caching + PostgreSQL materialized views volstaan. Redis toevoegen = extra infra om te beheren
Kubernetes Vercel/Supabase hosted = managed infra. K8s is overkill tot >10.000 concurrent users
Full FHIR server We bouwen FHIR-compatible, niet FHIR-native. Een volledige FHIR server (HAPI) is te zwaar. We exporteren FHIR, we zijn geen FHIR store

3. Data Architectuur

3.1 Database schema — kernmodel

Het datamodel is FHIR-inspired maar niet FHIR-native. We gebruiken PostgreSQL-native types en relaties, met FHIR export als API-laag.

-- Tenant (organisatie/instelling)
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,              -- url-friendly identifier
  settings JSONB DEFAULT '{}',            -- tenant-specifieke configuratie
  nen_7510_certified BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Locatie binnen een instelling
CREATE TABLE locations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,                     -- "Locatie Centrum", "Kliniek Noord"
  address JSONB,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Afdeling binnen een locatie
CREATE TABLE departments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  location_id UUID NOT NULL REFERENCES locations(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,                     -- "Afdeling Volwassenen", "Jeugd GGZ"
  department_type TEXT,                   -- 'klinisch', 'ambulant', 'dagbehandeling'
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Gebruiker (zorgverlener)
CREATE TABLE users (
  id UUID PRIMARY KEY REFERENCES auth.users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  department_id UUID REFERENCES departments(id),
  full_name TEXT NOT NULL,
  role TEXT NOT NULL,                     -- 'verpleegkundige', 'arts', 'psycholoog', 'admin', 'audit'
  big_number TEXT,                        -- BIG-registratie (wettelijk vereist)
  permissions TEXT[] DEFAULT '{}',        -- RBAC basis permissions
  active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Patiënt
CREATE TABLE patients (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  department_id UUID REFERENCES departments(id),
  bsn_encrypted BYTEA,                   -- BSN versleuteld opgeslagen (NEN 7510)
  bsn_hash TEXT,                          -- Hash voor lookup zonder decryptie
  full_name TEXT NOT NULL,
  date_of_birth DATE,
  gender TEXT,
  status TEXT DEFAULT 'actief',           -- 'actief', 'ontslagen', 'overdracht'
  admission_date DATE,
  legal_status TEXT,                      -- 'vrijwillig', 'wvggz_zorgmachtiging', 'wvggz_crisismaatregel'
  deleted_at TIMESTAMPTZ,                 -- soft delete
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Patiënt-zorgverlener toewijzing
CREATE TABLE patient_assignments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  patient_id UUID NOT NULL REFERENCES patients(id),
  user_id UUID NOT NULL REFERENCES users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  role TEXT NOT NULL,                     -- 'hoofdbehandelaar', 'verpleegkundige', 'mentor'
  active BOOLEAN DEFAULT true,
  assigned_at TIMESTAMPTZ DEFAULT now(),
  ended_at TIMESTAMPTZ,
  UNIQUE(patient_id, user_id, role)
);

-- Rapportages (alle types)
CREATE TABLE reports (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  patient_id UUID NOT NULL REFERENCES patients(id),
  author_id UUID NOT NULL REFERENCES users(id),
  report_type TEXT NOT NULL,              -- 'voortgang', 'observatie', 'incident', 'medicatie',
                                          -- 'contact', 'crisis', 'intake', 'behandeladvies',
                                          -- 'vrije_notitie', 'verpleegkundig'
  title TEXT,
  content TEXT NOT NULL,
  structured_data JSONB DEFAULT '{}',     -- category, severity, etc.
  shift_date DATE NOT NULL,               -- voor 07:00 = vorige dag
  version INTEGER DEFAULT 1,
  previous_version_id UUID REFERENCES reports(id),
  deleted_at TIMESTAMPTZ,                 -- soft delete, NOOIT hard delete
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Behandelplan
CREATE TABLE treatment_plans (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  patient_id UUID NOT NULL REFERENCES patients(id),
  author_id UUID NOT NULL REFERENCES users(id),
  status TEXT DEFAULT 'concept',          -- 'concept', 'actief', 'afgerond', 'herzien'
  goals JSONB DEFAULT '[]',               -- behandeldoelen
  interventions JSONB DEFAULT '[]',       -- interventies
  evaluation_date DATE,
  version INTEGER DEFAULT 1,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Afspraken / Agenda
CREATE TABLE appointments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  patient_id UUID REFERENCES patients(id),
  user_id UUID NOT NULL REFERENCES users(id),
  title TEXT NOT NULL,
  start_time TIMESTAMPTZ NOT NULL,
  end_time TIMESTAMPTZ NOT NULL,
  appointment_type TEXT,                  -- 'consult', 'groepstherapie', 'mdo', 'visite'
  status TEXT DEFAULT 'gepland',          -- 'gepland', 'bevestigd', 'geannuleerd', 'voltooid'
  location TEXT,
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Toestemmingen (Wvggz / AVG)
CREATE TABLE consents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  patient_id UUID NOT NULL REFERENCES patients(id),
  consent_type TEXT NOT NULL,             -- 'behandeling', 'datadeling', 'wvggz_zorgmachtiging'
  status TEXT NOT NULL,                   -- 'verleend', 'ingetrokken', 'verlopen'
  granted_by TEXT,                        -- patiënt, wettelijk vertegenwoordiger, rechter
  granted_at TIMESTAMPTZ,
  expires_at TIMESTAMPTZ,
  document_reference TEXT,                -- link naar juridisch document
  created_at TIMESTAMPTZ DEFAULT now()
);

3.2 Verpleegkundig rapportage — categorieën en shift-logica

// Report types die in de reports tabel worden opgeslagen
type ReportType =
  | 'voortgang'       // Dagelijkse voortgangsrapportage
  | 'observatie'       // Klinische observatie
  | 'incident'         // Incident of MIC-melding
  | 'medicatie'        // Medicatie-gerelateerd
  | 'contact'          // Contact met familie/instantie
  | 'crisis'           // Crisisinterventie
  | 'intake'           // Intakegesprek
  | 'behandeladvies'   // Advies aan behandelteam
  | 'vrije_notitie'    // Ongestructureerde notitie
  | 'verpleegkundig';  // Verpleegkundige rapportage

// Verpleegkundige subcategorieën (in structured_data.category)
type NursingCategory = 'medicatie' | 'adl' | 'gedrag' | 'incident' | 'observatie';

// Shift-logica: rapportage vóór 07:00 hoort bij de nachtdienst van de vorige dag
function calculateShiftDate(createdAt: Date): Date {
  const hour = createdAt.getHours();
  if (hour < 7) {
    return subDays(createdAt, 1); // date-fns
  }
  return createdAt;
}

// Dagdelen voor tijdlijn-weergave
type DayPart = 'nacht' | 'ochtend' | 'middag' | 'avond';
// nacht:   00:00 - 07:00
// ochtend: 07:00 - 12:00
// middag:  12:00 - 17:00
// avond:   17:00 - 00:00

3.3 Versioning en soft delete

Rapportages worden nooit hard verwijderd. Dit is een wettelijke eis (Wkkgz, NEN 7513).

// Soft delete: zet deleted_at, origineel blijft in database
async function softDeleteReport(reportId: string, userId: string) {
  await supabase
    .from('reports')
    .update({ deleted_at: new Date().toISOString() })
    .eq('id', reportId);

  await logAuditEvent({
    entityType: 'report',
    entityId: reportId,
    action: 'soft_delete',
    userId,
    purpose: 'user_requested_deletion',
  });
}

// Versioning: nieuwe versie aanmaken, link naar vorige
async function updateReport(reportId: string, newContent: string, userId: string) {
  const { data: original } = await supabase
    .from('reports')
    .select('*')
    .eq('id', reportId)
    .single();

  // Maak nieuwe versie aan
  const { data: newVersion } = await supabase
    .from('reports')
    .insert({
      ...original,
      id: undefined, // nieuwe UUID
      content: newContent,
      version: original.version + 1,
      previous_version_id: original.id,
      updated_at: new Date().toISOString(),
    })
    .select()
    .single();

  // Soft-delete de oude versie
  await supabase
    .from('reports')
    .update({ deleted_at: new Date().toISOString() })
    .eq('id', reportId);

  return newVersion;
}

4. Multi-Tenancy

4.1 Isolatiemodel: Shared Database + RLS

We kiezen voor een shared database met Row Level Security. Dit is de pragmatische keuze voor de Supabase-stack:

Waarom shared database + RLS:

  • Één codebase, één database, geen per-tenant infra
  • Supabase RLS is PostgreSQL-native (bewezen technologie)
  • Schaalbaar tot ~100 tenants zonder schema-explosie
  • Eenvoudiger te beheren dan separate databases

Waarom NIET separate databases (nu):

  • Operationele complexiteit (migraties per tenant, backups per tenant)
  • Supabase ondersteunt multi-database niet out of the box
  • Pas relevant bij >100 tenants of bij contractuele data-isolatie eisen

4.2 RLS-implementatie

-- Elke tabel met tenant_id krijgt deze policy
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE appointments ENABLE ROW LEVEL SECURITY;
ALTER TABLE treatment_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE consents ENABLE ROW LEVEL SECURITY;
ALTER TABLE patient_assignments ENABLE ROW LEVEL SECURITY;

-- Basis tenant-isolatie policy (template voor alle tabellen)
CREATE POLICY tenant_isolation ON patients
  USING (tenant_id = (
    SELECT tenant_id FROM users WHERE id = auth.uid()
  ))
  WITH CHECK (tenant_id = (
    SELECT tenant_id FROM users WHERE id = auth.uid()
  ));

-- Verfijnde policy: alleen toegewezen patiënten (voor klinische rollen)
CREATE POLICY assigned_patients_only ON patients
  FOR SELECT
  USING (
    tenant_id = (SELECT tenant_id FROM users WHERE id = auth.uid())
    AND (
      -- Gebruiker is toegewezen aan patiënt
      id IN (
        SELECT patient_id FROM patient_assignments
        WHERE user_id = auth.uid() AND active = true
      )
      -- OF gebruiker heeft 'alle_patienten' permissie (bijv. admin, dienstdoend arts)
      OR EXISTS (
        SELECT 1 FROM users
        WHERE id = auth.uid()
        AND 'alle_patienten' = ANY(permissions)
      )
    )
  );

4.3 Tenant-context in de applicatie

// middleware.ts — Zet tenant context voor elke request
import { NextResponse } from 'next/server';
import { createMiddlewareClient } from '@/lib/supabase/middleware';

export async function middleware(request: NextRequest) {
  const supabase = createMiddlewareClient(request);
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Haal tenant op uit user metadata (gezet bij registratie/SSO)
  const tenantId = user.user_metadata?.tenant_id;

  if (!tenantId) {
    return NextResponse.redirect(new URL('/onboarding', request.url));
  }

  // Zet als header zodat Server Components het kunnen lezen
  const response = NextResponse.next();
  response.headers.set('x-tenant-id', tenantId);
  return response;
}

4.4 Tenant-specifieke configuratie

// Instellingen per tenant (opgeslagen in tenants.settings JSONB)
interface TenantSettings {
  // Rapportage
  reportTypes: ReportType[];              // Welke report types beschikbaar zijn
  nursingCategories: NursingCategory[];   // Welke categorieën
  shiftChangeHour: number;                // Standaard 7, configureerbaar

  // Intent systeem
  enabledIntents: CortexIntent[];         // Welke intents actief zijn
  cortexLanguage: 'nl' | 'en';           // Taal voor intent classificatie
  enableVoiceInput: boolean;              // Deepgram aan/uit

  // Protocol Engine / Nudge
  enableNudges: boolean;
  protocolSources: string[];              // Welke protocollen geladen

  // Compliance
  auditRetentionYears: number;            // Standaard 7
  requireMFA: boolean;
  sessionTimeoutMinutes: number;          // Standaard 15

  // Branding (optioneel)
  logoUrl?: string;
  primaryColor?: string;
}

5. Audit Trail (NEN 7513)

5.1 Waarom dit niet-onderhandelbaar is

NEN 7513 schrijft voor dat alle toegang tot en wijziging van patiëntgegevens gelogd moet worden. Dit is geen nice-to-have — het is wettelijk verplicht voor elke zorginstelling in Nederland. Bij een audit moet je kunnen aantonen: wie heeft wat gedaan, wanneer, waar, en waarom.

5.2 Immutable audit log

-- Audit log tabel — write-once, NOOIT updaten of verwijderen
CREATE TABLE audit_logs (
  id BIGSERIAL PRIMARY KEY,
  event_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL,

  -- WIE
  user_id UUID NOT NULL,
  user_role TEXT NOT NULL,
  user_name TEXT NOT NULL,                -- Gedenormaliseerd (naam kan later wijzigen)

  -- WAT
  entity_type TEXT NOT NULL,              -- 'patient', 'report', 'appointment', 'treatment_plan'
  entity_id UUID NOT NULL,
  action TEXT NOT NULL,                   -- 'create', 'read', 'update', 'soft_delete', 'export'

  -- WANNEER
  timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),

  -- WAAR
  ip_address INET,
  user_agent TEXT,
  session_id UUID,
  source TEXT NOT NULL DEFAULT 'ui',      -- 'ui', 'intent_system', 'api', 'scheduled_job'

  -- WAAROM
  purpose TEXT NOT NULL,                  -- 'klinische_zorg', 'overdracht', 'audit_review', 'onderzoek'
  cortex_intent TEXT,                     -- Als actie via intent systeem kwam

  -- INTEGRITEIT
  data_hash TEXT NOT NULL,                -- SHA-256 hash van wie+wat+wanneer
  previous_hash TEXT,                     -- Keten-hash voor integriteitsverificatie

  -- CONTEXT
  metadata JSONB DEFAULT '{}'             -- Aanvullende context (bijv. gewijzigde velden)
) PARTITION BY RANGE (timestamp);

-- Maandelijkse partities (nodig bij miljoenen events per jaar)
CREATE TABLE audit_logs_2026_01 PARTITION OF audit_logs
  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE audit_logs_2026_02 PARTITION OF audit_logs
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- etc. — automatiseer met pg_partman of een cron job

-- IMMUTABILITEIT: triggers die UPDATE en DELETE blokkeren
CREATE OR REPLACE FUNCTION prevent_audit_mutation()
RETURNS TRIGGER AS $$
BEGIN
  RAISE EXCEPTION 'Audit logs zijn immutable — wijzigen of verwijderen is niet toegestaan';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_no_update
  BEFORE UPDATE ON audit_logs
  FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation();

CREATE TRIGGER audit_no_delete
  BEFORE DELETE ON audit_logs
  FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation();

-- Indexes voor forensische queries
CREATE INDEX idx_audit_tenant_time ON audit_logs(tenant_id, timestamp DESC);
CREATE INDEX idx_audit_entity ON audit_logs(entity_type, entity_id, timestamp DESC);
CREATE INDEX idx_audit_user ON audit_logs(user_id, timestamp DESC);
CREATE INDEX idx_audit_patient_access ON audit_logs(entity_id)
  WHERE entity_type = 'patient';

5.3 Applicatie-integratie

// lib/audit/logger.ts
import crypto from 'crypto';
import { createClient } from '@/lib/auth/server';

interface AuditEventInput {
  entityType: string;
  entityId: string;
  action: string;
  purpose: string;
  cortexIntent?: string;          // Welke intent triggerde dit?
  metadata?: Record<string, any>;
}

export async function logAuditEvent(input: AuditEventInput) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error('Audit log vereist geauthenticeerde user');

  // Bereken hash voor integriteitsverificatie
  const hashPayload = JSON.stringify({
    userId: user.id,
    entityType: input.entityType,
    entityId: input.entityId,
    action: input.action,
    timestamp: new Date().toISOString(),
  });
  const dataHash = crypto.createHash('sha256').update(hashPayload).digest('hex');

  // Haal vorige hash op voor keten-integriteit
  const { data: lastEvent } = await supabase
    .from('audit_logs')
    .select('data_hash')
    .eq('entity_type', input.entityType)
    .eq('entity_id', input.entityId)
    .order('timestamp', { ascending: false })
    .limit(1)
    .single();

  await supabase.from('audit_logs').insert({
    tenant_id: user.user_metadata.tenant_id,
    user_id: user.id,
    user_role: user.user_metadata.role,
    user_name: user.user_metadata.full_name,
    entity_type: input.entityType,
    entity_id: input.entityId,
    action: input.action,
    purpose: input.purpose,
    cortex_intent: input.cortexIntent,
    source: input.cortexIntent ? 'intent_system' : 'ui',
    data_hash: dataHash,
    previous_hash: lastEvent?.data_hash ?? null,
    metadata: input.metadata ?? {},
  });
}

// Gebruik in elke API route:
// Na rapport aanmaken:
await logAuditEvent({
  entityType: 'report',
  entityId: newReport.id,
  action: 'create',
  purpose: 'klinische_zorg',
  cortexIntent: 'dagnotitie',    // Als via intent systeem
  metadata: { reportType: 'verpleegkundig', category: 'observatie' },
});

// Na patiëntdossier openen:
await logAuditEvent({
  entityType: 'patient',
  entityId: patientId,
  action: 'read',
  purpose: 'klinische_zorg',
});

5.4 Forensische queries

-- "Wie heeft patiënt X bekeken in de laatste 30 dagen?"
SELECT user_name, user_role, action, timestamp, purpose, cortex_intent
FROM audit_logs
WHERE entity_type = 'patient' AND entity_id = $1
AND timestamp > now() - interval '30 days'
ORDER BY timestamp DESC;

-- "Alle acties van gebruiker Y vandaag"
SELECT entity_type, entity_id, action, purpose, timestamp
FROM audit_logs
WHERE user_id = $1 AND timestamp::date = CURRENT_DATE
ORDER BY timestamp DESC;

-- "Alle wijzigingen aan rapport Z met versiegeschiedenis"
SELECT action, timestamp, user_name, metadata
FROM audit_logs
WHERE entity_type = 'report' AND entity_id = $1
ORDER BY timestamp ASC;

-- Anomalie detectie: "Wie heeft >50 patiëntdossiers geopend in 1 uur?"
SELECT user_id, user_name, COUNT(*) as access_count
FROM audit_logs
WHERE entity_type = 'patient' AND action = 'read'
AND timestamp > now() - interval '1 hour'
GROUP BY user_id, user_name
HAVING COUNT(*) > 50;

6. Toegangscontrole (RBAC + ABAC)

6.1 Rollenmodel

GGZ-instellingen hebben een complex rollenmodel. We implementeren een hybride RBAC + ABAC systeem.

RBAC basis (wie mag in principe wat):

Rol Patiënt lezen Rapport schrijven Behandelplan Audit log Systeem config
verpleegkundige Toegewezen Ja Lezen Nee Nee
arts Afdeling Ja Schrijven Nee Nee
psycholoog Toegewezen Ja Schrijven Nee Nee
hoofdbehandelaar Afdeling Ja Goedkeuren Nee Nee
admin Alle Nee Nee Lezen Ja
audit Alle (read-only) Nee Nee Alle Nee

ABAC verfijning (context-afhankelijke regels):

// lib/access-control/policies.ts

interface AccessPolicy {
  id: string;
  name: string;
  description: string;
  conditions: PolicyCondition[];
  effect: 'allow' | 'deny';
}

type PolicyCondition =
  | { type: 'role'; roles: string[] }
  | { type: 'assignment'; required: boolean }        // Moet toegewezen zijn aan patiënt
  | { type: 'time_window'; start: string; end: string }  // Tijdsvenster (Wvggz)
  | { type: 'consent'; consentType: string }          // Toestemming vereist
  | { type: 'legal_status'; statuses: string[] }      // Juridische status patiënt
  | { type: 'data_classification'; maxLevel: string }; // Data gevoeligheidsniveau

// Voorbeeld: Toegang tot Wvggz-patiëntdossier
const wvggzAccessPolicy: AccessPolicy = {
  id: 'wvggz-patient-access',
  name: 'Wvggz patiënt toegangsbeleid',
  description: 'Beperkte toegang voor patiënten onder Wvggz-maatregel',
  conditions: [
    { type: 'role', roles: ['arts', 'verpleegkundige', 'psycholoog'] },
    { type: 'assignment', required: true },
    { type: 'consent', consentType: 'wvggz_zorgmachtiging' },
  ],
  effect: 'allow',
};

6.2 Policy evaluatie

// lib/access-control/evaluator.ts

export async function evaluateAccess(request: {
  userId: string;
  action: 'read' | 'write' | 'delete';
  resource: string;       // 'patient', 'report', 'treatment_plan'
  resourceId: string;
  tenantId: string;
}): Promise<{ allowed: boolean; reason?: string }> {

  const user = await getUser(request.userId);
  const policies = await getPoliciesForResource(request.resource, request.tenantId);

  for (const policy of policies) {
    const result = await evaluatePolicy(policy, { user, ...request });

    if (policy.effect === 'deny' && result.matched) {
      // Deny altijd voorrang
      return { allowed: false, reason: result.reason };
    }

    if (policy.effect === 'allow' && result.matched) {
      return { allowed: true };
    }
  }

  // Default deny (principle of least privilege)
  return { allowed: false, reason: 'Geen toepasselijk beleid gevonden' };
}

7. Encryptie & Data Bescherming

7.1 Encryptie strategie

Laag Methode Wat
In transit TLS 1.3 Alle verbindingen client ↔ server ↔ database
At rest (database) Supabase managed (AES-256) Volledige database encryptie
At rest (velden) Application-level AES-256-GCM BSN, diagnose, medicatie in audit logs
Backup Versleutelde backups Point-in-time recovery met encrypted storage

7.2 Gevoelige velden — application-level encryptie

// lib/encryption/field-encryption.ts
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
// Key management: gebruik Supabase Vault of AWS KMS in productie
const ENCRYPTION_KEY = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex');

export function encryptField(plaintext: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);

  let encrypted = cipher.update(plaintext, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag();

  // Format: iv:authTag:ciphertext
  return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}

export function decryptField(encrypted: string): string {
  const [ivHex, authTagHex, ciphertext] = encrypted.split(':');
  const decipher = crypto.createDecipheriv(
    ALGORITHM, ENCRYPTION_KEY, Buffer.from(ivHex, 'hex')
  );
  decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));

  let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

// BSN opslaan: hash voor lookup, encrypted voor weergave
export function storeBSN(bsn: string) {
  return {
    bsn_hash: crypto.createHash('sha256').update(bsn).digest('hex'),
    bsn_encrypted: encryptField(bsn),
  };
}

7.3 PII in AI prompts — minimalisatie

// lib/cortex/pii-filter.ts
// AI prompts krijgen NOOIT direct identificeerbare patiëntgegevens

export function sanitizeForAI(context: PatientContext): SanitizedContext {
  return {
    patientRef: `P-${context.patientId.slice(0, 8)}`, // Geen naam, geen BSN
    age: calculateAge(context.dateOfBirth),             // Leeftijd, geen geboortedatum
    gender: context.gender,
    activeConditions: context.conditions,               // Klinisch relevant
    recentReportCount: context.recentReports.length,    // Alleen count
    legalStatus: context.legalStatus,                   // Relevant voor Wvggz nudges
    // NIET meesturen: naam, BSN, adres, contactgegevens
  };
}

8. FHIR Interoperabiliteit

8.1 Strategie: FHIR-compatible, niet FHIR-native

We slaan data op in ons eigen PostgreSQL-model (optimaal voor onze use case) en bieden FHIR R4 export endpoints aan voor interoperabiliteit met andere systemen.

Waarom niet FHIR-native:

  • FHIR resources zijn generiek — ons model is geoptimaliseerd voor GGZ
  • Volledige FHIR server (HAPI) is Java/heavy — past niet bij onze stack
  • Nederlandse EPD-markt verwacht FHIR export, niet FHIR storage

8.2 FHIR export endpoints

// app/api/fhir/Patient/[id]/route.ts
// Vertaalt ons interne model naar FHIR R4 Patient resource

export async function GET(req: Request, { params }: { params: { id: string } }) {
  const supabase = await createClient();

  const { data: patient } = await supabase
    .from('patients')
    .select('*')
    .eq('id', params.id)
    .single();

  if (!patient) return Response.json({ error: 'Patient not found' }, { status: 404 });

  // Log FHIR export in audit trail
  await logAuditEvent({
    entityType: 'patient',
    entityId: params.id,
    action: 'export',
    purpose: 'fhir_interoperability',
  });

  // Vertaal naar FHIR R4
  const fhirPatient = {
    resourceType: 'Patient',
    id: patient.id,
    identifier: patient.bsn_hash ? [{
      system: 'http://fhir.nl/NamingSystem/bsn',
      value: patient.bsn_hash,  // Hash, niet de echte BSN
    }] : [],
    name: [{
      text: patient.full_name,
      family: patient.full_name.split(' ').pop(),
      given: patient.full_name.split(' ').slice(0, -1),
    }],
    gender: mapGender(patient.gender),
    birthDate: patient.date_of_birth,
    active: patient.status === 'actief',
  };

  return Response.json(fhirPatient, {
    headers: { 'Content-Type': 'application/fhir+json' },
  });
}

// FHIR Bundle export (alle data van een patiënt)
// app/api/fhir/Patient/[id]/$everything/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const patient = await getFHIRPatient(params.id);
  const observations = await getFHIRObservations(params.id);
  const conditions = await getFHIRConditions(params.id);

  return Response.json({
    resourceType: 'Bundle',
    type: 'searchset',
    total: 1 + observations.length + conditions.length,
    entry: [
      { resource: patient },
      ...observations.map(o => ({ resource: o })),
      ...conditions.map(c => ({ resource: c })),
    ],
  });
}

8.3 CDS Hooks integratie

// app/api/cds-hooks/patient-view/route.ts
// Wordt aangeroepen wanneer een clinicus een patiëntdossier opent
// Retourneert nudge-achtige suggesties in CDS Hooks formaat

export async function POST(req: Request) {
  const body = await req.json();
  const patientId = body.context?.patientId;

  // Evalueer protocol regels (dezelfde logic als Nudge systeem)
  const nudges = await evaluateProtocolRules(patientId);

  // Vertaal naar CDS Hooks cards
  const cards = nudges.map(nudge => ({
    uuid: nudge.id,
    summary: nudge.message,
    indicator: nudge.priority === 'P1' ? 'critical' : 'info',
    source: {
      label: nudge.source,          // 'GGZ Standaard Depressie'
      url: nudge.sourceUrl,
    },
    suggestions: nudge.suggestedAction ? [{
      label: nudge.suggestedAction.label,
      actions: [{
        type: 'create',
        description: nudge.suggestedAction.description,
        resource: nudge.suggestedAction.fhirResource,
      }],
    }] : [],
  }));

  return Response.json({ cards });
}

9. Observability & Monitoring

9.1 Drie pijlers

Pijler Tool Wat
Logs Pino → stdout → log aggregator Gestructureerde JSON logs, PII-gefilterd
Metrics OpenTelemetry → Grafana/Datadog Latency, error rate, classificatie-snelheid
Traces OpenTelemetry Request flow door de hele pipeline

9.2 Cortex-specifieke metrics

// lib/observability/cortex-metrics.ts

// Metrics die we tracken per intent classificatie:
interface ClassificationMetrics {
  totalClassifications: Counter;
  reflexHits: Counter;                // Opgelost door Reflex (snel)
  orchestratorFallbacks: Counter;     // Doorverwezen naar LLM (langzaam)
  classificationLatency: Histogram;   // ms per classificatie
  confidenceDistribution: Histogram;  // Verdeling van confidence scores
  intentDistribution: Counter;        // Welke intents het meest voorkomen
  entityExtractionErrors: Counter;    // Mislukte entity extractie
  nudgeSuggestions: Counter;          // Aantal nudge suggesties gegeven
  nudgeAccepted: Counter;             // Aantal nudges geaccepteerd door gebruiker
}

// Dashboard alerts:
// - Reflex hit rate < 60% → patronen moeten geüpdatet worden
// - Orchestrator latency p95 > 3s → AI provider probleem
// - Entity extraction error rate > 5% → extractie regels reviewen
// - Nudge acceptance rate < 10% → nudges zijn niet relevant genoeg

9.3 Health checks

// app/api/health/route.ts
export async function GET() {
  const checks = await Promise.allSettled([
    checkDatabase(),
    checkAnthropicAPI(),
    checkDeepgram(),
    checkSupabaseAuth(),
  ]);

  const status = checks.every(c => c.status === 'fulfilled') ? 'healthy' : 'degraded';

  return Response.json({
    status,
    timestamp: new Date().toISOString(),
    checks: {
      database: checks[0].status === 'fulfilled' ? 'up' : 'down',
      ai: checks[1].status === 'fulfilled' ? 'up' : 'down',
      speech: checks[2].status === 'fulfilled' ? 'up' : 'down',
      auth: checks[3].status === 'fulfilled' ? 'up' : 'down',
    },
    // Graceful degradation status
    capabilities: {
      intentClassification: checks[1].status === 'fulfilled' ? 'full' : 'reflex_only',
      voiceInput: checks[2].status === 'fulfilled' ? 'available' : 'unavailable',
      reporting: checks[0].status === 'fulfilled' ? 'available' : 'unavailable',
    },
  });
}

10. Resilience & Graceful Degradation

10.1 Drie degradatieniveaus

Het systeem moet altijd blijven functioneren, ook als externe services uitvallen.

Niveau 0: VOLLEDIG OPERATIONEEL
├── Intent systeem: Reflex + Orchestrator (LLM)
├── Spraak: Deepgram beschikbaar
├── Nudges: Protocol Engine actief
└── Alle functies beschikbaar

Niveau 1: VERMINDERD (AI niet beschikbaar)
├── Intent systeem: Alleen Reflex (lokale patronen)
│   → ~70% van intents wordt nog herkend
│   → Complexe/ambigue input krijgt "niet begrepen" melding
├── Spraak: Deepgram beschikbaar (of ook uit → alleen tekst)
├── Nudges: Alleen statische regels (geen AI-suggesties)
└── Traditionele UI: Volledig beschikbaar ✓

Niveau 2: MINIMAAL (database traag/beperkt)
├── Intent systeem: Uit
├── Traditionele UI: Read-only modus
├── Nieuwe rapportages: Lokaal gebufferd, sync later
└── Banner: "Beperkte beschikbaarheid — sommige functies zijn tijdelijk niet beschikbaar"

10.2 Circuit breaker voor AI providers

// lib/resilience/circuit-breaker.ts

interface CircuitBreakerState {
  status: 'closed' | 'open' | 'half-open';
  failureCount: number;
  lastFailure: Date | null;
  nextAttempt: Date | null;
}

const AI_CIRCUIT_BREAKER: CircuitBreakerState = {
  status: 'closed',
  failureCount: 0,
  lastFailure: null,
  nextAttempt: null,
};

const MAX_FAILURES = 3;
const RECOVERY_TIME_MS = 30_000; // 30 seconden

export async function callWithCircuitBreaker<T>(
  fn: () => Promise<T>,
  fallback: () => T,
): Promise<T> {
  if (AI_CIRCUIT_BREAKER.status === 'open') {
    if (AI_CIRCUIT_BREAKER.nextAttempt && new Date() > AI_CIRCUIT_BREAKER.nextAttempt) {
      AI_CIRCUIT_BREAKER.status = 'half-open';
    } else {
      return fallback(); // Gebruik fallback (Reflex-only)
    }
  }

  try {
    const result = await fn();
    if (AI_CIRCUIT_BREAKER.status === 'half-open') {
      AI_CIRCUIT_BREAKER.status = 'closed';
      AI_CIRCUIT_BREAKER.failureCount = 0;
    }
    return result;
  } catch (error) {
    AI_CIRCUIT_BREAKER.failureCount++;
    AI_CIRCUIT_BREAKER.lastFailure = new Date();

    if (AI_CIRCUIT_BREAKER.failureCount >= MAX_FAILURES) {
      AI_CIRCUIT_BREAKER.status = 'open';
      AI_CIRCUIT_BREAKER.nextAttempt = new Date(Date.now() + RECOVERY_TIME_MS);
    }

    return fallback();
  }
}

11. Nederlandse Zorgwetgeving — Compliance Matrix

11.1 Relevante wetgeving en impact op architectuur

Wet Wat het regelt Impact op het systeem
Wlz (Wet langdurige zorg) Recht op langdurige zorg Moet zorgindicaties kunnen registreren en rapporteren
Wvggz (Wet verplichte GGZ) Verplichte/onvrijwillige zorg Legal status tracking, extra audit requirements, toestemmingsregistratie
Wkkgz (Wet kwaliteit klachten geschillen zorg) Kwaliteit en veiligheid Incident-rapportage (MIC), klachtenregistratie, dossierplicht
Wmcz (Wet medezeggenschap cliënten zorginstellingen) Cliëntenparticipatie Niet direct, maar relevant voor governance
AVG/GDPR Persoonsgegevens bescherming Toestemming, recht op inzage, dataportabiliteit, DPIA
NEN 7510 Informatiebeveiliging zorg RBAC, encryptie, access control, incident management
NEN 7512 Veilige gegevensuitwisseling TLS, authenticatie bij data-export, FHIR security
NEN 7513 Logging van zorggebeurtenissen Immutable audit trail, 7 jaar bewaarplicht
BIG-wet Registratie zorgverleners BIG-nummer opslaan bij gebruikers, verifiëren

11.2 Wvggz-specifieke functionaliteit

De Wvggz verdient extra aandacht omdat het direct impact heeft op het datamodel en de toegangscontrole:

// Juridische statussen die het systeem moet ondersteunen
type LegalStatus =
  | 'vrijwillig'                    // Vrijwillige opname
  | 'wvggz_zorgmachtiging'         // Rechterlijke zorgmachtiging
  | 'wvggz_crisismaatregel'        // Burgemeester-crisismaatregel
  | 'wvggz_machtiging_voortgezet'  // Voortgezette crisismaatregel
  | 'wzd_rechterlijke_machtiging'; // Wet zorg en dwang (andere doelgroep)

// Impact op toegang:
// - Wvggz-patiënten: extra logging bij elke dossierinzage
// - Wvggz-patiënten: toestemmingsregistratie verplicht
// - Wvggz-patiënten: dwangmaatregelen moeten geregistreerd worden
// - Wvggz-patiënten: periodieke evaluatie door geneesheer-directeur

// Impact op Nudge systeem:
// Protocol Engine regel voorbeeld:
// "Als patient.legalStatus = 'wvggz_zorgmachtiging'
//  EN laatste evaluatie > 6 maanden geleden
//  → Nudge: 'Evaluatie zorgmachtiging is verlopen, plan evaluatie in'"

12. Knowledge Layer — Detail

12.1 Vier kennisbronnen

De Knowledge Layer is de tweede unieke component van het systeem. Het voedt de Protocol Engine (Nudge) met actuele, verifieerbare kennis.

┌─────────────────────────────────────────────────────────────┐
│                    KNOWLEDGE LAYER                           │
│                                                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
│  │ 1. WETGEVING │  │ 2. PROTOCOLLEN│  │ 3. KWALITEITS│      │
│  │              │  │              │  │   DOCUMENTEN │      │
│  │ Wlz, Wvggz  │  │ GGZ Std.    │  │              │      │
│  │ Wkkgz, NEN  │  │ V&VN, FMS   │  │ Instelling-  │      │
│  │ 7510/12/13  │  │ NHG, CBO    │  │ specifiek    │      │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘      │
│         │                 │                  │               │
│         └────────┬────────┴──────────────────┘               │
│                  ▼                                            │
│  ┌──────────────────────────────────────────────┐           │
│  │            PROTOCOL RULES ENGINE              │           │
│  │  Gevalideerde regels met bron-verwijzing      │           │
│  │  (completedAction, rules) → suggestions[]     │           │
│  └──────────────────┬───────────────────────────┘           │
│                     │                                        │
│  ┌──────────────────┴───────────────────────────┐           │
│  │          4. EPD DATA (eigen data)             │           │
│  │  Patiëntgegevens, rapportages, behandelplannen│           │
│  │  → Data-driven conditions voor nudges         │           │
│  └──────────────────────────────────────────────┘           │
└─────────────────────────────────────────────────────────────┘

12.2 Protocol Rule structuur

// lib/knowledge/protocol-rule.ts

interface ProtocolRule {
  id: string;
  name: string;
  description: string;

  // Bron
  source: {
    type: 'wetgeving' | 'richtlijn' | 'instelling' | 'epd_data' | 'ai_suggested';
    name: string;               // "GGZ Standaard Depressie v3.0"
    section?: string;           // "Hoofdstuk 4.2 - Medicatiebewaking"
    url?: string;               // Link naar bron
    version: string;
  };

  // Validatie
  validatedBy: string;          // userId van validator
  validatedAt: Date;
  status: 'concept' | 'gevalideerd' | 'actief' | 'verlopen';

  // Trigger
  trigger: {
    afterIntent?: CortexIntent[];   // Na welke intents evalueren
    afterAction?: string[];         // Na welke acties
    schedule?: string;              // Cron voor periodieke checks
  };

  // Conditie
  condition: RuleCondition;

  // Actie
  suggestion: {
    message: string;                // "Overweeg lithiumspiegel te controleren"
    priority: 'P1' | 'P2' | 'P3';
    suggestedIntent?: CortexIntent; // Welke intent als vervolgactie
    evidence: string;               // "Volgens GGZ Standaard Bipolair §4.3"
  };
}

// Condities kunnen statisch of data-driven zijn
type RuleCondition =
  | { type: 'static'; check: (context: RuleContext) => boolean }
  | { type: 'query'; table: string; filter: Record<string, any>; expect: 'exists' | 'not_exists' | 'count_gt' | 'count_lt'; threshold?: number };

// Voorbeeld: data-driven conditie
const medicatieReviewRule: ProtocolRule = {
  id: 'med-review-quarterly',
  name: 'Kwartaal medicatiereview',
  description: 'Herinnering voor medicatiereview elke 3 maanden',
  source: {
    type: 'richtlijn',
    name: 'GGZ Standaard Psychose',
    section: '6.3 Medicatiebewaking',
    version: '2024.1',
  },
  validatedBy: 'user-uuid-arts',
  validatedAt: new Date('2026-01-15'),
  status: 'actief',
  trigger: { afterIntent: ['dagnotitie', 'overdracht'] },
  condition: {
    type: 'query',
    table: 'reports',
    filter: {
      patient_id: '$activePatient',
      report_type: 'medicatie',
      'structured_data->category': 'review',
    },
    expect: 'not_exists',  // Geen medicatiereview gevonden
    // in de laatste 90 dagen (impliciet via created_at filter)
  },
  suggestion: {
    message: 'Laatste medicatiereview is >3 maanden geleden. Overweeg een review in te plannen.',
    priority: 'P2',
    suggestedIntent: 'create_appointment',
    evidence: 'GGZ Standaard Psychose §6.3: "Evalueer medicatie minimaal elke 3 maanden"',
  },
};

12.3 RAG voor protocolkennis

Voor het ontsluiten van omvangrijke protocolteksten (GGZ Standaarden zijn honderden pagina's) gebruiken we RAG (Retrieval-Augmented Generation):

// lib/knowledge/rag-pipeline.ts

// Stap 1: Indexeer protocoldocumenten (eenmalig of bij update)
// Gebruik pgvector in Supabase voor vector storage

// Tabel:
// protocol_chunks (id, source_id, chunk_text, embedding vector(1536), metadata jsonb)

// Stap 2: Bij een nudge-evaluatie, zoek relevante chunks
async function findRelevantProtocolChunks(query: string, topK: number = 5) {
  const embedding = await generateEmbedding(query); // OpenAI of Anthropic

  const { data } = await supabase.rpc('match_protocol_chunks', {
    query_embedding: embedding,
    match_threshold: 0.7,
    match_count: topK,
  });

  return data; // Relevante protocolfragmenten
}

// Stap 3: AI suggereert candidate rules op basis van protocol
// Mens (arts/kwaliteitsmedewerker) valideert en activeert
async function suggestRulesFromProtocol(protocolId: string) {
  const chunks = await getProtocolChunks(protocolId);

  const suggestions = await anthropic.messages.create({
    model: 'claude-sonnet-4-5-20250514',
    system: `Je bent een klinische informaticus. Analyseer dit protocol en stel
             concrete nudge-regels voor die een EPD-systeem kan evalueren.
             Elke regel moet: een trigger, een conditie en een suggestie bevatten.
             Verwijs naar het exacte hoofdstuk/paragraaf.`,
    messages: [{ role: 'user', content: chunks.map(c => c.text).join('\n\n') }],
  });

  // Parse suggestions → ProtocolRule[] met status 'ai_suggested'
  // Toon in admin UI voor validatie door arts
  return parseSuggestedRules(suggestions, { status: 'ai_suggested' });
}

Trade-off: Rules vs RAG:

  • Rules (handmatig gevalideerd): Betrouwbaar, traceerbaar, maar arbeidsintensief
  • RAG (AI-gesuggereerd): Schaalbaar, maar vereist menselijke validatie
  • Onze keuze: RAG voor candidate generation, mens voor validatie. Nooit automatisch actief.

13. API Structuur

13.1 Route-indeling

app/api/
├── auth/                        # Authenticatie (Supabase Auth wrappers)
│   ├── login/route.ts
│   ├── logout/route.ts
│   └── callback/route.ts        # SSO callback
│
├── patients/                    # Patiënt CRUD
│   ├── route.ts                 # GET (lijst), POST (aanmaken)
│   ├── [id]/route.ts           # GET, PATCH (details)
│   └── [id]/assignments/route.ts
│
├── reports/                     # Rapportage CRUD
│   ├── route.ts                 # GET (lijst met filters), POST
│   └── [id]/route.ts           # GET, PATCH, DELETE (soft)
│
├── treatment-plans/             # Behandelplannen
│   ├── route.ts
│   └── [id]/route.ts
│
├── appointments/                # Agenda
│   ├── route.ts
│   └── [id]/route.ts
│
├── cortex/                      # Intent systeem
│   ├── classify/route.ts        # POST: tekst → intent + entities
│   ├── chat/route.ts            # POST: streaming chat (SSE)
│   ├── context/route.ts         # GET: huidige context
│   └── nudge/route.ts           # GET: evalueer nudges voor context
│
├── overdracht/                  # Overdracht / handover
│   ├── route.ts                 # GET: overzicht
│   ├── [patientId]/route.ts    # GET: patient detail
│   └── generate/route.ts       # POST: AI samenvatting genereren
│
├── fhir/                        # FHIR R4 export
│   ├── Patient/[id]/route.ts
│   ├── Observation/route.ts
│   └── Bundle/route.ts
│
├── cds-hooks/                   # CDS Hooks endpoints
│   └── patient-view/route.ts
│
├── audit/                       # Audit log queries (admin only)
│   ├── route.ts                 # GET: zoeken in logs
│   └── export/route.ts          # GET: CSV/JSON export
│
├── admin/                       # Systeembeheer
│   ├── tenants/route.ts
│   ├── users/route.ts
│   └── settings/route.ts
│
├── health/route.ts              # Health check endpoint
└── deepgram/route.ts            # Speech-to-text proxy

13.2 API route patroon

// Standaard patroon voor elke API route

import { createClient } from '@/lib/auth/server';
import { logAuditEvent } from '@/lib/audit/logger';
import { evaluateAccess } from '@/lib/access-control/evaluator';
import { z } from 'zod';

// 1. Zod schema voor validatie
const CreateReportSchema = z.object({
  patientId: z.string().uuid(),
  reportType: z.enum(['voortgang', 'observatie', 'incident', /* ... */]),
  content: z.string().min(1).max(10000),
  structuredData: z.object({
    category: z.enum(['medicatie', 'adl', 'gedrag', 'incident', 'observatie']).optional(),
  }).optional(),
});

export async function POST(req: Request) {
  // 2. Auth check
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return Response.json({ error: 'Niet geauthenticeerd' }, { status: 401 });

  // 3. Input validatie
  const body = await req.json();
  const parsed = CreateReportSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json({ error: 'Ongeldige invoer', details: parsed.error.issues }, { status: 400 });
  }

  // 4. Toegangscontrole (RBAC + ABAC)
  const access = await evaluateAccess({
    userId: user.id,
    action: 'write',
    resource: 'report',
    resourceId: parsed.data.patientId,
    tenantId: user.user_metadata.tenant_id,
  });
  if (!access.allowed) {
    return Response.json({ error: 'Geen toegang', reason: access.reason }, { status: 403 });
  }

  // 5. Business logic
  const shiftDate = calculateShiftDate(new Date());
  const { data: report, error } = await supabase
    .from('reports')
    .insert({
      tenant_id: user.user_metadata.tenant_id,
      patient_id: parsed.data.patientId,
      author_id: user.id,
      report_type: parsed.data.reportType,
      content: parsed.data.content,
      structured_data: parsed.data.structuredData ?? {},
      shift_date: shiftDate,
    })
    .select()
    .single();

  if (error) return Response.json({ error: 'Fout bij opslaan' }, { status: 500 });

  // 6. Audit log
  await logAuditEvent({
    entityType: 'report',
    entityId: report.id,
    action: 'create',
    purpose: 'klinische_zorg',
    metadata: { reportType: parsed.data.reportType },
  });

  // 7. Response
  return Response.json(report, { status: 201 });
}

14. EPD Modules

14.1 Module-overzicht

app/epd/
├── dashboard/                   # Hoofddashboard + Cortex command center
├── patients/                    # Patiëntenoverzicht en -beheer
│   └── [id]/                   # Individueel patiëntdossier
│       ├── overzicht/          # Samenvattingsweergave
│       ├── rapportages/        # Rapportagegeschiedenis (tijdlijn)
│       ├── behandelplan/       # Behandelplan + doelen
│       ├── medicatie/          # Medicatieoverzicht
│       └── agenda/             # Patiënt-specifieke afspraken
├── rapportage/                  # Rapportage-invoer workspace
│   └── nieuw/                  # Nieuw rapport formulier
├── overdracht/                  # Overdracht overzicht (dienstwisseling)
├── agenda/                      # Team/persoonlijke agenda (FullCalendar)
├── clients/                     # Cliëntbeheer (admin)
└── instellingen/                # Systeeminstellingen (admin)
    ├── gebruikers/             # Gebruikersbeheer
    ├── afdelingen/             # Afdelingsconfiguratie
    ├── protocollen/            # Protocol/nudge regels beheer
    └── audit/                   # Audit log viewer

14.2 Intent → Module mapping

Het intent-systeem navigeert naar of pre-fillt modules:

Intent Module Actie
dagnotitie rapportage/nieuw Pre-fill formulier met patiënt, categorie, inhoud
zoeken patients Filter patiëntenlijst op zoekterm
overdracht overdracht Open overdracht met patiëntfocus
agenda_query agenda Filter op datum/patiënt
create_appointment agenda Open nieuw-afspraak formulier
cancel_appointment agenda Open annulerings-dialog
reschedule_appointment agenda Open verplaats-dialog

15. Bouwvolgorde

15.1 Fasering

FASE 1: FUNDAMENT (maand 1-2)
├── Database schema + migraties
├── Auth + multi-tenancy (RLS)
├── Basis UI: patiëntenlijst, dossier, formulieren
├── Rapportage CRUD (het meest gebruikte scherm)
├── Audit trail (NEN 7513) — vanaf dag 1 meebouwen
└── Deliverable: werkend EPD zonder intent-systeem

FASE 2: INTENT SYSTEEM MVP (maand 2-3)
├── Intent Registry (database-driven)
├── Reflex classifier (lokale patronen)
├── Command input overlay (⌘K)
├── 3-4 intents: dagnotitie, zoeken, overdracht, agenda_query
├── Pre-fill pattern: intent → open formulier met data
└── Deliverable: EPD + intent-laag voor routinetaken

FASE 3: INTELLIGENCE (maand 3-4)
├── Orchestrator (LLM classificatie)
├── Entity resolution met fuzzy matching
├── Spraak-input (Deepgram)
├── Overdracht met AI-samenvatting
├── Nudge systeem v1 (statische regels)
└── Deliverable: slimme EPD met AI-assist

FASE 4: KNOWLEDGE LAYER (maand 4-5)
├── Protocol Engine met gevalideerde regels
├── RAG pipeline voor protocoldocumenten
├── Eerste protocollen inladen (GGZ Standaarden)
├── Admin UI voor regelbeheer
└── Deliverable: evidence-based nudges

FASE 5: COMPLIANCE & SCHAAL (maand 5-6)
├── FHIR R4 export endpoints
├── CDS Hooks integratie
├── Encryptie verfijning (field-level)
├── ABAC policies (Wvggz-specifiek)
├── Observability (Pino, OpenTelemetry)
├── Performance optimalisatie
└── Deliverable: compliance-ready systeem

DOORLOPEND:
├── Testing (unit, integration, e2e)
├── CI/CD pipeline
├── Documentatie
└── Gebruikerstesten met zorgverleners

16. Beslissingenlog

# Beslissing Alternatieven overwogen Rationale
D1 Modular monolith (geen microservices) Microservices, serverless Team <5 devs, operationele eenvoud, Supabase is al managed
D2 Shared database + RLS (geen separate DBs) Separate databases per tenant Supabase-native, schaalbaar tot ~100 tenants, eenvoudiger migraties
D3 FHIR-compatible export (geen FHIR-native) Full FHIR server (HAPI) Te zwaar voor onze stack, Nederlandse markt verwacht export niet storage
D4 Hybrid RBAC + ABAC Alleen RBAC, alleen ABAC RBAC te simpel voor Wvggz, ABAC alleen te complex. Hybride geeft flexibiliteit
D5 Application-level field encryptie Database-level transparent encryption Meer controle over wat versleuteld is, nodig voor BSN en diagnoses in audit logs
D6 Pino voor logging (geen Winston) Winston, Bunyan, console.log Laagste overhead, native JSON, PII filtering pipeline
D7 pgvector voor RAG (geen Pinecone) Pinecone, Weaviate, Chroma Supabase-native, geen extra service, voldoende voor protocol-volumes
D8 Audit trail vanaf dag 1 Later toevoegen Retrofit is exponentieel duurder, NEN 7513 is wettelijk verplicht
D9 Dual interface (traditioneel + intent) Alleen intent, alleen traditioneel Adoptie vereist vertrouwde UI, intent is versneller niet vervanging
D10 Immutable audit logs met hash chain Append-only zonder hash Hash chain geeft cryptografisch bewijs van integriteit (NEN 7513)

Appendix A: Relatie met het Intent Systeem Document

Dit document beschrijft de enterprise-schil. Het intent-systeem zelf — met de vijf bouwblokken (Intent Registry, Classification Pipeline, Entity Resolution, Action System, Protocol Engine) — is volledig beschreven in intent-system-architectuur-nl.md.

De twee documenten samen vormen de complete architectuur:

intent-system-architectuur-nl.md     enterprise-epd-architectuur.md
┌────────────────────────────┐      ┌──────────────────────────────┐
│ Hoe het intent systeem      │      │ Hoe het enterprise EPD       │
│ intern werkt:               │      │ eromheen werkt:              │
│                             │      │                              │
│ • Intent Registry           │◄────►│ • Multi-tenancy              │
│ • Classification Pipeline   │      │ • Audit trail (NEN 7513)     │
│ • Entity Resolution         │      │ • Toegangscontrole (RBAC+ABAC)│
│ • Action System             │      │ • Encryptie & data bescherming│
│ • Protocol Engine (Nudge)   │      │ • FHIR interoperabiliteit    │
│                             │      │ • Knowledge Layer            │
│                             │      │ • Observability              │
│                             │      │ • Resilience                 │
│                             │      │ • Zorgwetgeving compliance   │
│                             │      │ • EPD modules & UI           │
└────────────────────────────┘      └──────────────────────────────┘

Appendix B: Technologie Referenties

Technologie Documentatie Versie
Next.js nextjs.org/docs 14 (App Router)
Supabase supabase.com/docs Latest
PostgreSQL postgresql.org/docs 15+
Anthropic Claude docs.anthropic.com claude-sonnet-4-5 / claude-opus-4
Deepgram developers.deepgram.com Nova-2
Tailwind CSS tailwindcss.com 3.x
shadcn/ui ui.shadcn.com Latest
Zod zod.dev 3.x
Zustand zustand-demo.pmnd.rs 4.x
Pino getpino.io 8.x
OpenTelemetry opentelemetry.io 1.x
FHIR R4 hl7.org/fhir/R4 4.0.1
CDS Hooks cds-hooks.org 2.0

Appendix C: Standaarden Referenties

Standaard Organisatie Relevant voor
NEN 7510 NEN Informatiebeveiliging (→ RBAC, encryptie, access control)
NEN 7512 NEN Veilige gegevensuitwisseling (→ FHIR, TLS)
NEN 7513 NEN Logging (→ audit trail)
FHIR R4 HL7 Interoperabiliteit (→ export endpoints)
CDS Hooks HL7 Clinical decision support (→ nudge/protocol engine)
GGZ Standaarden Akwa GGZ Behandelprotocollen (→ Knowledge Layer)
V&VN richtlijnen V&VN Verpleegkundige protocollen (→ Knowledge Layer)
AVG/GDPR EU Privacy (→ encryptie, toestemming, dataportabiliteit)