46 KiB
Platform Architectuur — Self-Hosted, Connectiviteit & Deployment Modellen
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: Uitbreiding op
enterprise-epd-architectuur.md— beschrijft self-hosted stack, connectiviteitslaag en deployment modellen
Leeswijzer
Dit document adresseert drie architectuurwijzigingen ten opzichte van het enterprise-EPD-document:
- Self-hosted stack — Supabase, Supabase Auth en Vercel worden vervangen door self-hosted alternatieven
- Connectiviteitslaag — Het intent-systeem + Knowledge Layer worden ontkoppeld van het EPD en kunnen als onafhankelijke service draaien
- Deployment modellen — Drie manieren om het systeem in te zetten: standalone, als laag bovenop bestaand EPD, of hybrid
De kernbeslissing die dit document beschrijft: het intent-systeem en de Knowledge Layer zijn geen onderdeel van het EPD — ze zijn een platform dat met elk EPD kan communiceren via een gestandaardiseerde connectiviteitslaag.
1. Architectuurwijziging: van Monoliet naar Platform
1.1 Waarom dit ertoe doet
De Nederlandse GGZ-markt heeft bestaande EPD-systemen: HiX (Chipsoft), Nexus (Nexus), User (Gerimedica), Epic, etc. Een nieuw EPD bouwen dat deze vervangt is een multi-jaar, multi-miljoen traject met enorme weerstand.
Maar: het intent-systeem + Knowledge Layer kan als losse laag bovenop elk EPD draaien. Dat opent twee markten:
- Markt A: Instellingen die een nieuw EPD willen → volledig platform (eigen EPD + intent + knowledge)
- Markt B: Instellingen die hun bestaande EPD houden → intent + knowledge als add-on laag
Dit verandert de architectuur fundamenteel:
VOORHEEN (monoliet):
┌─────────────────────────────────┐
│ Intent + Knowledge + EPD │ ← Eén systeem, alles gekoppeld
│ Alles in één Next.js app │
└─────────────────────────────────┘
NU (platform):
┌─────────────────────────────────┐
│ INTENT PLATFORM │ ← Onafhankelijke service
│ Intent Systeem + Knowledge Layer│
│ Eigen API, eigen database │
└──────────────┬──────────────────┘
│ Connector API (gestandaardiseerd)
│
┌──────────┼──────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Eigen EPD│ │ HiX │ │ Nexus │ ← Elk EPD via connector
│(Next.js)│ │(Chipsoft)│ │ │
└────────┘ └────────┘ └────────┘
1.2 Drie deployment modellen
| Model | Beschrijving | Doelgroep |
|---|---|---|
| Standalone | Volledig platform: eigen EPD + intent + knowledge | Nieuwe instellingen, kleine GGZ |
| Add-on | Intent + knowledge als laag bovenop bestaand EPD | HiX/Nexus klanten |
| Hybrid | Eigen EPD voor specifieke modules + koppeling met bestaand EPD | Instellingen in transitie |
2. Platform Architectuur
2.1 Twee onafhankelijke services
Het systeem bestaat uit twee deploybare units die onafhankelijk van elkaar kunnen draaien:
┌─────────────────────────────────────────────────────────────────┐
│ INTENT PLATFORM SERVICE │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Intent Engine │ │
│ │ ├── Intent Registry (definities) │ │
│ │ ├── Reflex Classifier (lokale patronen) │ │
│ │ ├── Orchestrator (LLM classificatie) │ │
│ │ ├── Entity Resolution (fuzzy matching) │ │
│ │ └── Action Resolver (intent → actie-instructie) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Knowledge Layer │ │
│ │ ├── Protocol Rules Store (gevalideerde regels) │ │
│ │ ├── RAG Pipeline (pgvector, protocol indexering) │ │
│ │ ├── Nudge Evaluator (protocol → suggesties) │ │
│ │ └── Kennisbron Management (importers, validatie UI) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Connector API (gestandaardiseerd) │ │
│ │ ├── EPD Adapter Interface (abstract) │ │
│ │ ├── Patient Data Connector (lezen uit EPD) │ │
│ │ ├── Action Dispatcher (schrijven naar EPD) │ │
│ │ └── Event Listener (ontvangen van EPD events) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Eigen database: PostgreSQL (intent logs, protocol rules, │
│ knowledge vectors, configuratie) │
└─────────────────────────────────────────────────────────────────┘
│
Connector API (REST + Webhooks)
│
┌─────────────────────────────┼───────────────────────────────────┐
│ EPD SERVICE (optioneel eigen EPD) │
│ │
│ ├── Patiënt management │
│ ├── Rapportage (CRUD, versioning) │
│ ├── Behandelplan │
│ ├── Agenda │
│ ├── Medicatie │
│ └── Traditionele UI (formulieren, lijsten) │
│ │
│ Eigen database: PostgreSQL (patiëntdata, rapportages, etc.) │
└─────────────────────────────────────────────────────────────────┘
2.2 Waarom twee services, niet één?
| Reden | Toelichting |
|---|---|
| Onafhankelijke deployment | Intent platform kan geüpdatet worden zonder EPD downtime |
| Meerdere EPD's bedienen | Eén intent platform kan meerdere EPD-koppelingen hebben |
| Verschillende eigenaren | Intent platform = jouw product. EPD = kan van een ander zijn |
| Schaalverschil | Intent platform (AI, vectorDB) schaalt anders dan EPD (CRUD, rapportage) |
| Licentiemodel | Intent platform als SaaS/licentie, EPD als optionele module |
2.3 Communicatie tussen services
// De Intent Platform communiceert met elk EPD via een Connector API.
// Elke EPD-koppeling implementeert dezelfde interface.
// === CONNECTOR API INTERFACE ===
interface EPDConnector {
// --- Lezen uit EPD ---
getPatient(patientId: string): Promise<PatientSummary>;
searchPatients(query: string): Promise<PatientSummary[]>;
getRecentReports(patientId: string, since: Date): Promise<ReportSummary[]>;
getAppointments(userId: string, dateRange: DateRange): Promise<Appointment[]>;
getActiveMedication(patientId: string): Promise<Medication[]>;
getTreatmentPlan(patientId: string): Promise<TreatmentPlan | null>;
getLegalStatus(patientId: string): Promise<LegalStatus>;
// --- Schrijven naar EPD ---
createReport(data: CreateReportRequest): Promise<ReportResult>;
createAppointment(data: CreateAppointmentRequest): Promise<AppointmentResult>;
updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<AppointmentResult>;
cancelAppointment(id: string, reason: string): Promise<void>;
// --- Events ontvangen van EPD ---
onPatientAdmitted(callback: (event: PatientEvent) => void): void;
onReportCreated(callback: (event: ReportEvent) => void): void;
onShiftChange(callback: (event: ShiftEvent) => void): void;
// --- Metadata ---
getCapabilities(): ConnectorCapabilities;
getVersion(): string;
}
// Elke EPD-koppeling implementeert deze interface:
// - OwnEPDConnector: voor het eigen EPD (directe database calls)
// - HiXConnector: voor Chipsoft HiX (via HiX API/HL7)
// - NexusConnector: voor Nexus (via Nexus API)
// - FHIRConnector: voor elk FHIR-compatible systeem
// === DATA TYPES (EPD-agnostisch) ===
interface PatientSummary {
id: string; // EPD-specifiek ID
externalId?: string; // BSN hash of ander extern ID
displayName: string; // Weergavenaam
dateOfBirth?: string; // ISO date
gender?: 'male' | 'female' | 'other' | 'unknown';
department?: string; // Afdeling
legalStatus?: LegalStatus; // Wvggz status
activeFlags?: string[]; // 'risico_suïcide', 'valrisico', etc.
}
interface ReportSummary {
id: string;
patientId: string;
authorName: string;
reportType: string; // 'voortgang', 'observatie', etc.
category?: string; // 'medicatie', 'adl', 'gedrag'
content: string; // Platte tekst (max 10.000 chars)
createdAt: string; // ISO datetime
shiftDate: string; // ISO date
includeInHandover: boolean;
metadata?: Record<string, unknown>; // EPD-specifieke extra data
}
interface ConnectorCapabilities {
canRead: string[]; // ['patient', 'report', 'appointment', 'medication']
canWrite: string[]; // ['report', 'appointment']
canSubscribe: string[]; // ['patient_admitted', 'report_created']
supportsRealtime: boolean;
supportsFHIR: boolean;
maxBatchSize: number;
}
2.4 Connector implementaties
// === EIGEN EPD CONNECTOR (directe database) ===
// connectors/own-epd-connector.ts
import { db } from '@/lib/db';
import { patients, reports, appointments } from '@/schema';
export class OwnEPDConnector implements EPDConnector {
constructor(private tenantId: string) {}
async getPatient(patientId: string): Promise<PatientSummary> {
const patient = await db
.select()
.from(patients)
.where(and(
eq(patients.id, patientId),
eq(patients.tenantId, this.tenantId)
))
.limit(1);
return mapToPatientSummary(patient[0]);
}
async searchPatients(query: string): Promise<PatientSummary[]> {
const results = await db
.select()
.from(patients)
.where(and(
eq(patients.tenantId, this.tenantId),
ilike(patients.fullName, `%${query}%`)
))
.limit(20);
return results.map(mapToPatientSummary);
}
async createReport(data: CreateReportRequest): Promise<ReportResult> {
const report = await db.insert(reports).values({
tenantId: this.tenantId,
patientId: data.patientId,
authorId: data.authorId,
reportType: data.reportType,
content: data.content,
structuredData: data.structuredData,
shiftDate: calculateShiftDate(new Date()),
}).returning();
return { id: report[0].id, success: true };
}
// ... overige methoden
}
// === HIX CONNECTOR (Chipsoft API) ===
// connectors/hix-connector.ts
export class HiXConnector implements EPDConnector {
constructor(
private baseUrl: string, // HiX API endpoint
private apiKey: string, // HiX API key
private organizationId: string // HiX organisatie
) {}
async getPatient(patientId: string): Promise<PatientSummary> {
// HiX gebruikt HL7 FHIR R4 API
const response = await fetch(
`${this.baseUrl}/fhir/Patient/${patientId}`,
{ headers: { 'Authorization': `Bearer ${this.apiKey}` } }
);
const fhirPatient = await response.json();
return mapFHIRToPatientSummary(fhirPatient);
}
async createReport(data: CreateReportRequest): Promise<ReportResult> {
// HiX: create via DocumentReference FHIR resource
const fhirDoc = mapToFHIRDocumentReference(data);
const response = await fetch(
`${this.baseUrl}/fhir/DocumentReference`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/fhir+json',
},
body: JSON.stringify(fhirDoc),
}
);
const result = await response.json();
return { id: result.id, success: response.ok };
}
getCapabilities(): ConnectorCapabilities {
return {
canRead: ['patient', 'report', 'appointment', 'medication'],
canWrite: ['report'], // HiX beperkt schrijftoegang
canSubscribe: [], // HiX heeft geen realtime events
supportsRealtime: false,
supportsFHIR: true,
maxBatchSize: 100,
};
}
}
// === GENERIC FHIR CONNECTOR ===
// connectors/fhir-connector.ts
export class FHIRConnector implements EPDConnector {
constructor(
private fhirBaseUrl: string,
private authToken: string
) {}
// Werkt met elk FHIR R4 compatible systeem
// Epic, Cerner, open-source FHIR servers, etc.
async getPatient(patientId: string): Promise<PatientSummary> {
const response = await fetch(
`${this.fhirBaseUrl}/Patient/${patientId}`,
{ headers: { 'Authorization': `Bearer ${this.authToken}` } }
);
return mapFHIRToPatientSummary(await response.json());
}
// ...
}
2.5 Intent Platform API
Het intent platform biedt zelf een API aan die door elke frontend (eigen UI, embedded widget, of CLI) kan worden aangesproken:
// === INTENT PLATFORM REST API ===
// POST /api/platform/classify
// Classificeer tekst naar intent + entities
// Input: { text: string, context?: { patientId?, userId? } }
// Output: { intent, entities, confidence, suggestedAction }
// POST /api/platform/chat
// Streaming chat met intent-aware context
// Input: { message: string, context?, history? }
// Output: SSE stream met tekst + actionBlocks
// POST /api/platform/transcribe
// Spraak naar tekst
// Input: audio blob
// Output: { transcript, confidence }
// GET /api/platform/nudges
// Haal actieve nudges op voor context
// Input: { patientId, lastAction? }
// Output: { nudges: NudgeSuggestion[] }
// POST /api/platform/nudges/:id/accept
// Markeer nudge als geaccepteerd
// Input: { nudgeId }
// Output: { suggestedAction }
// GET /api/platform/protocols
// Beheer protocol rules (admin)
// POST /api/platform/protocols
// Voeg protocol rule toe (admin, validated)
// GET /api/platform/health
// Health check
// GET /api/platform/capabilities
// Wat kan dit platform? Welke intents, welke connectors?
3. Self-Hosted Technologie Stack
3.1 Gewijzigde stack
Componenten die wijzigen ten opzichte van het enterprise-EPD-document:
| Was (managed) | Wordt (self-hosted) | Waarom |
|---|---|---|
| Supabase PostgreSQL | PostgreSQL 16 (Docker) | Volledige controle, data residency NL |
| Supabase Auth | Keycloak | Enterprise SSO/SAML, MFA, NEN 7510 |
| Supabase Realtime | PostgreSQL LISTEN/NOTIFY + WebSockets | Geen externe dependency |
| Supabase Storage | Lokaal filesystem of Garage (S3-compatible) | Eenvoud, encryptie |
| PostgREST (Supabase) | Drizzle ORM | Type-safe, 2MB footprint, RLS-compatible |
| Vercel | Hetzner VPS + Coolify | EU data residency, €15-25/maand |
| — (nieuw) | Caddy | Reverse proxy, automatische HTTPS |
| — (nieuw) | Redis + BullMQ | Job queue voor AI taken, notificaties |
| — (nieuw) | Postal | Self-hosted email voor notificaties |
3.2 Stack per service
INTENT PLATFORM SERVICE:
├── Runtime: Node.js 20 (Docker)
├── Framework: Next.js 14 of standalone Express/Fastify
├── Database: PostgreSQL 16 (pgvector, pgcrypto, pg_trgm)
├── ORM: Drizzle
├── Auth: Keycloak (OIDC) → Auth.js in Next.js
├── AI: Anthropic Claude API (extern)
├── Spraak: Deepgram API (extern)
├── Vector store: pgvector (in PostgreSQL)
├── Job queue: BullMQ + Redis
├── Logging: Pino (structured JSON)
└── Reverse proxy: Caddy (HTTPS, routing)
EPD SERVICE (optioneel):
├── Runtime: Node.js 20 (Docker)
├── Framework: Next.js 14 (App Router)
├── Database: PostgreSQL 16 (eigen instance of shared)
├── ORM: Drizzle
├── Auth: Keycloak (gedeeld met platform)
├── UI: Tailwind + shadcn/ui
├── State: Zustand
├── Validatie: Zod
└── Icons: Lucide React
3.3 Waarom Keycloak voor auth
| Requirement | Keycloak biedt |
|---|---|
| SSO/SAML voor GGZ-instellingen | Native SAML 2.0 + OIDC |
| Azure AD federatie (standaard bij GGZ) | Identity Provider federation |
| MFA (NEN 7510 eis) | TOTP, WebAuthn, SMS |
| Rolbeheer | Realm roles + client roles |
| Multi-tenancy | Eén realm per instelling, of één realm met groups |
| Audit logging | Alle auth events gelogd |
| Session management | Configureerbare timeout (standaard 15 min) |
| Self-hosted | Docker image, PostgreSQL backend |
Integratie met Next.js:
// auth.ts — Auth.js v5 met Keycloak
import NextAuth from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER_URL!, // https://auth.jouwdomein.nl/realms/zorg
}),
],
callbacks: {
async jwt({ token, account }) {
if (account?.access_token) {
const decoded = jwtDecode(account.access_token);
token.roles = decoded.realm_access?.roles ?? [];
token.tenantId = decoded.tenant_id;
token.organizationName = decoded.organization_name;
}
return token;
},
async session({ session, token }) {
session.user.roles = token.roles as string[];
session.user.tenantId = token.tenantId as string;
return session;
},
},
});
// Middleware: bescherm alle /epd/* en /api/* routes
export { auth as middleware } from './auth';
export const config = { matcher: ['/epd/:path*', '/api/:path*'] };
Azure AD federatie (typisch voor GGZ-instellingen):
Stap 1: Keycloak als Identity Broker
→ Instelling A heeft Azure AD
→ Configureer Azure AD als Identity Provider in Keycloak
→ Keycloak federeert login naar Azure AD
→ Gebruiker logt in met instellings-credentials
Stap 2: Role mapping
→ Azure AD group "Verpleegkundigen" → Keycloak role "verpleegkundige"
→ Azure AD group "Artsen" → Keycloak role "arts"
→ Keycloak role → PostgreSQL RLS via app.user_role setting
Stap 3: Multi-instelling
→ Instelling A: Azure AD federation in Keycloak realm "instelling-a"
→ Instelling B: ADFS federation in Keycloak realm "instelling-b"
→ Elke realm heeft eigen rollen en configuratie
3.4 Waarom Drizzle ORM (vervangt PostgREST)
// lib/db.ts — Database connectie met Drizzle
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
});
export const db = drizzle(pool, { schema });
// RLS context instellen per request
export async function withTenantContext(tenantId: string, userId: string, fn: () => Promise<any>) {
const client = await pool.connect();
try {
await client.query(`SET app.current_tenant_id = '${tenantId}'`);
await client.query(`SET app.current_user_id = '${userId}'`);
return await fn();
} finally {
// Reset settings
await client.query('RESET app.current_tenant_id');
await client.query('RESET app.current_user_id');
client.release();
}
}
Waarom Drizzle boven Prisma:
| Aspect | Drizzle | Prisma |
|---|---|---|
| Bundle size | ~2 MB | ~15 MB |
| RLS support | Directe pg queries, RLS werkt | Prisma Client omzeilt soms RLS |
| Migraties | SQL-first, volledige controle | Schema-first, soms drift |
| Connection pooling | Eigen Pool (pg) | PgBouncer of Prisma Accelerate (managed) |
| Self-hosted | Zero dependencies | Prisma engine binary nodig |
3.5 Hosting: Hetzner + Coolify
┌─────────────────────────────────────────────────────────────┐
│ HETZNER VPS — Amsterdam (€15-25/mnd) │
│ Ubuntu 22.04, 4 vCPU, 8GB RAM, 160GB NVMe │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Coolify (deployment platform) │ │
│ │ ├── Auto-deploy from GitHub │ │
│ │ ├── Docker Compose orchestratie │ │
│ │ ├── Let's Encrypt SSL automatisch │ │
│ │ └── Health monitoring │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Caddy │ │ Intent │ │ EPD │ │ Keycloak │ │
│ │ :443 │──│ Platform │ │ Service │ │ :8080 │ │
│ │ reverse │ │ :3001 │ │ :3000 │ │ │ │
│ │ proxy │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └──────────┘ │ │ │ │
│ └──────┬──────┴──────────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ PostgreSQL 16 │ │
│ │ ├── epd_db (patiëntdata) │ │
│ │ ├── platform_db (intents)│ │
│ │ └── keycloak_db (auth) │ │
│ │ :5432 │ │
│ └──────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Redis │ │ Postal │ │
│ │ :6379 │ │ email │ │
│ │ (BullMQ) │ │ :25 │ │
│ └──────────┘ └──────────┘ │
│ │
│ Storage: /data/files/ (encrypted, NFS mount of lokaal) │
└─────────────────────────────────────────────────────────────┘
Docker Compose (productie):
# docker-compose.yml
version: '3.8'
services:
# Reverse proxy
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: always
# Intent Platform
intent-platform:
build:
context: ./intent-platform
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql://platform:${DB_PASS}@postgres:5432/platform_db
KEYCLOAK_ISSUER_URL: https://auth.${DOMAIN}/realms/zorg
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY}
REDIS_URL: redis://redis:6379
depends_on:
- postgres
- redis
restart: always
# EPD Service (optioneel, alleen bij standalone deployment)
epd-service:
build:
context: ./epd-service
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql://epd:${DB_PASS}@postgres:5432/epd_db
KEYCLOAK_ISSUER_URL: https://auth.${DOMAIN}/realms/zorg
INTENT_PLATFORM_URL: http://intent-platform:3001
depends_on:
- postgres
- intent-platform
restart: always
# PostgreSQL
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init.sql
restart: always
# Keycloak
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak_db
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${DB_PASS}
KC_HOSTNAME: auth.${DOMAIN}
KC_PROXY_HEADERS: xforwarded
depends_on:
- postgres
restart: always
# Redis (job queue)
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: always
# Postal (email, optioneel)
postal:
image: postalserver/postal:latest
environment:
POSTAL_ADMIN_TOKEN: ${POSTAL_TOKEN}
volumes:
- postal_data:/opt/postal/app/file_store
restart: always
volumes:
postgres_data:
redis_data:
caddy_data:
postal_data:
Caddyfile:
# Caddyfile
{DOMAIN} {
reverse_proxy epd-service:3000
}
platform.{DOMAIN} {
reverse_proxy intent-platform:3001
}
auth.{DOMAIN} {
reverse_proxy keycloak:8080
}
3.6 Kosten vergelijking
| Component | Managed (Supabase + Vercel) | Self-hosted (Hetzner) |
|---|---|---|
| Database | €25/mnd (Supabase Pro) | €0 (in VPS) |
| Hosting | €20/mnd (Vercel Pro) | €15-25/mnd (Hetzner VPS) |
| Auth | €0 (Supabase incl.) | €0 (Keycloak) |
| €10/mnd (Postmark) | €0 (Postal) | |
| Job queue | €25/mnd (extern) | €0 (Redis in VPS) |
| Backup | €5/mnd | €3/mnd (Hetzner snapshots) |
| Totaal/mnd | ~€85 | ~€20-30 |
| Totaal/jaar | ~€1.020 | ~€250-360 |
Trade-off: Self-hosted is goedkoper maar vereist meer DevOps kennis. Voor een team van 2-5 devs dat toch al compliance moet regelen is dit acceptabel.
4. Connectiviteitslaag — Detail
4.1 Connector Registry
Het platform onderhoudt een registry van beschikbare connectors:
// lib/connectors/registry.ts
interface ConnectorRegistration {
id: string;
name: string; // 'own-epd', 'hix', 'nexus', 'fhir-generic'
type: 'internal' | 'fhir' | 'hl7' | 'custom';
status: 'active' | 'inactive' | 'error';
config: ConnectorConfig;
capabilities: ConnectorCapabilities;
lastHealthCheck: Date;
errorCount: number;
}
interface ConnectorConfig {
baseUrl?: string; // API endpoint van het EPD
authType: 'oauth2' | 'apikey' | 'saml' | 'internal';
credentials: {
clientId?: string;
clientSecret?: string; // Encrypted opgeslagen
apiKey?: string; // Encrypted opgeslagen
};
mappings?: {
patientIdField: string; // Hoe het EPD patient IDs noemt
reportTypeMapping: Record<string, string>; // Onze types → EPD types
};
}
// Connector factory
function createConnector(registration: ConnectorRegistration): EPDConnector {
switch (registration.name) {
case 'own-epd':
return new OwnEPDConnector(registration.config);
case 'hix':
return new HiXConnector(
registration.config.baseUrl!,
registration.config.credentials.apiKey!,
registration.config.mappings!
);
case 'fhir-generic':
return new FHIRConnector(
registration.config.baseUrl!,
registration.config.credentials.apiKey!
);
default:
throw new Error(`Onbekende connector: ${registration.name}`);
}
}
4.2 Hoe het intent systeem de connector gebruikt
// lib/cortex/action-resolver.ts
// Het Action System gebruikt NIET direct de database, maar de connector
export class ActionResolver {
constructor(private connector: EPDConnector) {}
async resolveAction(intent: ClassifiedIntent): Promise<ActionInstruction> {
switch (intent.type) {
case 'dagnotitie': {
// Haal patiënt op via connector (werkt met elk EPD)
const patient = intent.entities.patientName
? (await this.connector.searchPatients(intent.entities.patientName))[0]
: null;
return {
type: 'prefill_form',
form: 'dagnotitie',
prefillData: {
patient: patient ? { id: patient.id, name: patient.displayName } : null,
category: intent.entities.category,
content: intent.entities.content,
},
// Na submit: connector.createReport() aanroepen
submitAction: 'connector.createReport',
};
}
case 'overdracht': {
// Haal rapportages op via connector
const patients = await this.connector.searchPatients(''); // Alle actieve patiënten
const summaries = await Promise.all(
patients.map(async p => ({
patient: p,
reports: await this.connector.getRecentReports(p.id, shiftStart()),
}))
);
return {
type: 'render_view',
view: 'overdracht',
data: summaries,
};
}
case 'agenda_query': {
const appointments = await this.connector.getAppointments(
intent.context.userId,
intent.entities.dateRange
);
return {
type: 'render_view',
view: 'agenda',
data: appointments,
};
}
}
}
}
4.3 Nudge evaluatie via connector
// lib/cortex/protocol-engine/evaluator.ts
// Protocol Engine haalt patiëntdata op via connector
export class ProtocolEvaluator {
constructor(
private connector: EPDConnector,
private rulesStore: ProtocolRulesStore
) {}
async evaluateAfterAction(
completedAction: CompletedAction,
patientId: string
): Promise<NudgeSuggestion[]> {
// Haal context op via connector (EPD-agnostisch)
const [patient, recentReports, medication, legalStatus] = await Promise.all([
this.connector.getPatient(patientId),
this.connector.getRecentReports(patientId, thirtyDaysAgo()),
this.connector.getActiveMedication(patientId),
this.connector.getLegalStatus(patientId),
]);
const context: RuleContext = {
patient,
recentReports,
activeMedication: medication,
legalStatus,
completedAction,
};
// Evalueer regels tegen context
const rules = await this.rulesStore.getActiveRules({
afterIntent: completedAction.intent,
});
return rules
.filter(rule => this.evaluateCondition(rule.condition, context))
.map(rule => ({
ruleId: rule.id,
message: rule.suggestion.message,
priority: rule.suggestion.priority,
evidence: rule.suggestion.evidence,
source: rule.source,
}));
}
}
4.4 Capabilities-based UI
Omdat niet elk EPD alle functies ondersteunt, past de UI zich aan:
// De frontend vraagt capabilities op en toont alleen wat beschikbaar is
async function initializeUI() {
const capabilities = await connector.getCapabilities();
return {
showCreateReport: capabilities.canWrite.includes('report'),
showCreateAppointment: capabilities.canWrite.includes('appointment'),
showMedication: capabilities.canRead.includes('medication'),
showLiveUpdates: capabilities.supportsRealtime,
// Intent systeem: disable intents die het EPD niet ondersteunt
disabledIntents: getUnsupportedIntents(capabilities),
};
}
// Voorbeeld: HiX connector zegt canWrite: ['report']
// → Create appointment intent wordt uitgeschakeld
// → UI toont melding: "Afspraken aanmaken via HiX"
5. Deployment Modellen — Detail
5.1 Model A: Standalone (eigen EPD + platform)
Voor: Nieuwe instellingen, kleine GGZ, pilotprojecten
┌────────────────────────────────────┐
│ Hetzner VPS │
│ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ Intent │ │ Eigen EPD │ │
│ │ Platform │◄│ Service │ │
│ │ │ │ (Next.js) │ │
│ └──────┬──────┘ └──────┬───────┘ │
│ │ │ │
│ ┌──────┴───────────────┴──────┐ │
│ │ PostgreSQL (2 databases) │ │
│ └─────────────────────────────┘ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ Keycloak │ │ Redis │ │
│ └─────────────┘ └──────────────┘ │
└────────────────────────────────────┘
Connector: OwnEPDConnector (directe DB calls)
Kosten: €20-30/mnd
Complexiteit: Laag (alles op één machine)
5.2 Model B: Add-on (platform bovenop bestaand EPD)
Voor: HiX/Nexus klanten die intent-laag willen
┌────────────────────────┐ ┌────────────────────┐
│ Hetzner VPS │ │ Bestaand EPD │
│ │ │ (bijv. HiX) │
│ ┌──────────────────┐ │ │ │
│ │ Intent Platform │ │◄───│ FHIR R4 API │
│ │ + Knowledge Layer │ │ │ of HL7 interface │
│ └──────┬───────────┘ │ └────────────────────┘
│ │ │
│ ┌──────┴──────┐ │ Connector: HiXConnector of FHIRConnector
│ │ PostgreSQL │ │ Platform haalt data OP uit EPD
│ │ (platform DB │ │ Platform stuurt acties NAAR EPD
│ │ alleen) │ │ Platform slaat GEEN patiëntdata op
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Keycloak │ │ Auth: federeert naar instelling's Azure AD
│ └─────────────┘ │
└────────────────────────┘
Kosten: €15-20/mnd (minder: geen EPD database)
Complexiteit: Medium (connector configuratie)
Vereist: API-toegang tot bestaand EPD
5.3 Model C: Hybrid
Voor: Instellingen in transitie
┌────────────────────────────────────────┐
│ Hetzner VPS │
│ │
│ ┌──────────────────┐ │
│ │ Intent Platform │ │
│ │ + Knowledge Layer │ │
│ └──────┬───────────┘ │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────────┐ │
│ │Eigen │ │ HiX │ ← Via connector │
│ │EPD │ │ Connector│ │
│ │module │ │ │ │
│ └──────┘ └──────────┘ │
│ (bijv. (bestaand EPD │
│ rapportage voor medicatie, │
│ + overdracht) behandelplan) │
└────────────────────────────────────────┘
Use case: Instelling gebruikt HiX voor medicatie en behandelplan,
maar wil betere rapportage en overdracht.
→ Eigen rapportage module + intent systeem
→ Patiëntdata komt uit HiX via connector
→ Rapportages worden opgeslagen in eigen DB
→ Overdracht combineert data uit beide bronnen
6. Impact op Use Cases
6.1 Use case flow met connector
Het eerder beschreven UC-01 (Dagnotitie aanmaken) werkt nu zo:
Zorgverlener → "Notitie Jan medicatie onrustige nacht"
│
▼
┌──────────────────┐
│ Intent Platform │
│ │
│ 1. Classify │ Reflex: "notitie" → dagnotitie
│ 2. Extract │ "Jan" → patientName, "medicatie" → category
│ 3. Resolve │ connector.searchPatients("Jan") → Patient #427
│ 4. Action │ { type: 'prefill_form', form: 'dagnotitie', ... }
│ │
└────────┬─────────┘
│ ActionInstruction
▼
┌──────────────────┐
│ Frontend (UI) │
│ │
│ Render formulier │ Patient: Jan de Vries ✓
│ met prefill data │ Categorie: medicatie ✓
│ │ Content: "onrustige nacht" ✓
│ [Gebruiker klikt │
│ "Opslaan"] │
│ │
└────────┬─────────┘
│ CreateReportRequest
▼
┌──────────────────┐
│ Intent Platform │
│ │
│ connector │ → OwnEPDConnector.createReport()
│ .createReport() │ OF HiXConnector.createReport()
│ │ OF FHIRConnector.createReport()
│ │
│ Audit log │ Wie, wat, wanneer, via welke connector
│ │
│ Protocol Engine │ Evalueer nudges:
│ .evaluate() │ → connector.getRecentReports(Jan)
│ │ → connector.getActiveMedication(Jan)
│ │ → "Check vitale functies?"
└──────────────────┘
Het maakt niet uit welk EPD erachter zit. De flow is identiek. Alleen de connector implementatie verschilt.
6.2 Welke use cases werken met welk deployment model?
| Use Case | Standalone (A) | Add-on (B) | Hybrid (C) |
|---|---|---|---|
| UC-01 Dagnotitie | ✅ | ✅ (als EPD report-write ondersteunt) | ✅ |
| UC-05 AI Overdracht | ✅ | ✅ (leest rapportages uit EPD) | ✅ |
| UC-07 Patiënt zoeken | ✅ | ✅ (via EPD API) | ✅ |
| UC-10 Afspraak maken | ✅ | ⚠️ (afhankelijk van EPD capabilities) | ✅/⚠️ |
| UC-20 Nudge suggesties | ✅ | ✅ (leest medicatie/rapportages uit EPD) | ✅ |
| UC-21 Behandelplan | ✅ | ❌ (te complex voor connector) | ⚠️ (EPD of eigen) |
| UC-24 Wvggz registratie | ✅ | ❌ (te specifiek) | ⚠️ |
| UC-26 Audit log | ✅ | ✅ (platform eigen audit) | ✅ |
| UC-33 Noodtoegang | ✅ | ⚠️ (EPD-afhankelijk) | ⚠️ |
Leeswijzer: ✅ volledig ondersteund, ⚠️ afhankelijk van EPD capabilities, ❌ niet ondersteund in dit model
7. Gewijzigde Bouwvolgorde
Met de platformsplitsing verandert de volgorde:
FASE 0: INFRASTRUCTUUR (week 1-2)
├── Hetzner VPS opzetten + Coolify
├── Docker Compose met PostgreSQL, Keycloak, Redis, Caddy
├── CI/CD pipeline (GitHub Actions → Coolify)
├── Basis Keycloak configuratie (realm, rollen, test users)
└── Deliverable: draaiende infrastructuur, lege services
FASE 1: INTENT PLATFORM CORE (week 3-6)
├── Platform database schema (intent logs, protocol rules, connector config)
├── Intent Registry (database-driven)
├── Reflex Classifier + Orchestrator (migratie uit prototype)
├── Entity Resolution (migratie uit prototype)
├── Connector API interface definitie
├── OwnEPDConnector (eerste implementatie)
├── Platform REST API (/classify, /chat, /nudge)
├── Audit trail (NEN 7513) op platformniveau
└── Deliverable: werkend intent platform met eigen-EPD connector
FASE 2: EPD SERVICE (week 7-10)
├── EPD database schema (patients, reports, appointments, etc.)
├── Multi-tenancy (RLS)
├── RBAC + ABAC
├── Rapportage CRUD + versioning
├── Overdracht + AI samenvatting
├── Agenda
├── Traditionele UI (formulieren, lijsten, tijdlijn)
└── Deliverable: werkend EPD + intent platform (standalone model)
FASE 3: KNOWLEDGE LAYER + PROTOCOL ENGINE (week 11-14)
├── Protocol Rules Store
├── RAG Pipeline (pgvector)
├── Nudge evaluator met connector-based data ophalen
├── Regelvalidatie admin UI
├── Eerste protocollen inladen
└── Deliverable: evidence-based nudges
FASE 4: CONNECTIVITEIT (week 15-18)
├── FHIR Connector (generiek, voor elk FHIR R4 systeem)
├── HiX Connector (indien HiX-klant beschikbaar voor test)
├── Connector admin UI (configuratie, health monitoring)
├── Capabilities-based UI aanpassing
├── FHIR export endpoints
├── CDS Hooks integratie
└── Deliverable: platform draait als add-on op extern EPD
FASE 5: COMPLIANCE & HARDENING (week 19-22)
├── Field encryption (BSN, diagnoses)
├── Wvggz module
├── Toestemming service
├── Break-the-glass noodtoegang
├── Observability (Pino, OpenTelemetry)
├── Load testing
├── Penetratietest
└── Deliverable: productie-klaar systeem
8. Beslissingenlog (aanvullingen)
| # | Beslissing | Alternatieven | Rationale |
|---|---|---|---|
| D11 | Self-hosted PostgreSQL (geen Supabase) | Supabase Cloud, Neon, CockroachDB | Volledige controle, data residency NL, geen vendor lock-in |
| D12 | Keycloak voor auth (geen Supabase Auth) | Zitadel, Authentik, Auth.js standalone | SAML/SSO voor GGZ (Azure AD), battle-tested, NEN 7510 |
| D13 | Drizzle ORM (geen Prisma, geen PostgREST) | Prisma, Kysely, raw pg | 2MB footprint, RLS-compatible, SQL-first, self-hosted |
| D14 | Hetzner + Coolify (geen Vercel) | AWS, Azure, DigitalOcean, bare Docker | EU data residency, €15-25/mnd, Coolify = simpelste self-hosted |
| D15 | Caddy als reverse proxy (geen Nginx) | Nginx, Traefik | Automatische HTTPS, simpelste config, minder misconfiguratie |
| D16 | Platform als aparte service (niet in EPD) | Monoliet, microservices | Onafhankelijk deploybaar, werkt met meerdere EPD's |
| D17 | Connector interface (adapter pattern) | Directe EPD API calls, message bus | Standaard interface, nieuwe EPD's toevoegen = nieuwe connector |
| D18 | BullMQ + Redis (geen externe job service) | Quirrel, node-cron, pg-boss | Betrouwbare job persistentie, retry logic, self-hosted |
| D19 | Postal voor email (geen Postmark/SendGrid) | Postmark, SendGrid, Amazon SES | Self-hosted, geen data bij externe partij, NEN 7510 |
Appendix: Relatie tussen alle architectuurdocumenten
docs/architecture/
│
├── intent-system-architectuur-nl.md
│ Beschrijft: Hoe het intent systeem intern werkt
│ Scope: 5 bouwblokken, classificatie, entity resolution, nudge
│
├── enterprise-epd-architectuur.md
│ Beschrijft: Enterprise EPD schil (multi-tenancy, audit, security)
│ Stack: Supabase + Vercel (VERVANGEN door dit document)
│ Status: Functioneel nog geldig, stack is gewijzigd
│
├── usecases-technische-componenten.md
│ Beschrijft: 33 use cases × 34 componenten validatie
│ Scope: Gap analyse, bouwvolgorde, kruistabel
│
└── platform-connectiviteit-selfhosted.md ← DIT DOCUMENT
Beschrijft: Self-hosted stack, platform splitsing, connectors
Vervangt: Stack keuzes uit enterprise-epd-architectuur.md
Voegt toe: Connector API, deployment modellen, Keycloak, Drizzle