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

46 KiB
Raw Permalink Blame History

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

// 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)
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:

// 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