1176 lines
46 KiB
Markdown
1176 lines
46 KiB
Markdown
# 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
|
||
```
|