Files
triqura-ecd/docs/architecture/platform-connectiviteit-selfhosted.md
2026-07-09 23:16:28 +02:00

1176 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
1. **Self-hosted stack** — Supabase, Supabase Auth en Vercel worden vervangen door self-hosted alternatieven
2. **Connectiviteitslaag** — Het intent-systeem + Knowledge Layer worden ontkoppeld van het EPD en kunnen als onafhankelijke service draaien
3. **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
```typescript
// 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
```typescript
// === 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:
```typescript
// === 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:**
```typescript
// 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)
```typescript
// 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):**
```yaml
# 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
# 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) |
| Email | €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:
```typescript
// 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
```typescript
// 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
```typescript
// 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:
```typescript
// 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
```