# 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; searchPatients(query: string): Promise; getRecentReports(patientId: string, since: Date): Promise; getAppointments(userId: string, dateRange: DateRange): Promise; getActiveMedication(patientId: string): Promise; getTreatmentPlan(patientId: string): Promise; getLegalStatus(patientId: string): Promise; // --- Schrijven naar EPD --- createReport(data: CreateReportRequest): Promise; createAppointment(data: CreateAppointmentRequest): Promise; updateAppointment(id: string, data: UpdateAppointmentRequest): Promise; cancelAppointment(id: string, reason: string): Promise; // --- 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; // 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 { 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 { 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 { 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 { // 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 { // 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 { 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) { 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; // 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 { 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 { // 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 ```