# 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
```
