59 KiB
Enterprise Componenten Uitbreiding — Voorbereiding & Interfaces
Type: Architectuur Specificatiedocument Versie: 1.0 Datum: 2026-02-14 Doelgroep: LLM-agents, developers, architecten Taal: Nederlands (tekst), Engels (technische termen en code) Relatie: Uitbreiding op
usecases-technische-componenten.md— voegt 10 nieuwe componenten toe (C34–C43) Status: Voorbereiding — interfaces en integratiepunten gedefinieerd, nog niet gebouwd
Leeswijzer
Dit document beschrijft 10 aanvullende technische componenten die nodig zijn voor een enterprise-ready EPD. Ze zijn nog niet gebouwd, maar de architectuur is zo voorbereid dat ze naadloos ingepast kunnen worden:
- Interface definities — TypeScript interfaces die de contracten vastleggen
- Database schema — Tabellen die aangemaakt kunnen worden wanneer nodig
- Integratiepunten — Hoe elk component aansluit op bestaande componenten (C01–C33)
- Technologiekeuzes — Welke libraries/tools en waarom
- Fasering — Wanneer bouwen in relatie tot het bouwplan
De componenten volgen de nummering uit usecases-technische-componenten.md (C34+).
Component Overzicht
LAAG 6: PLATFORM SERVICES (nieuw)
├── C34 Notificatie Service (in-app + email + escalatie)
├── C35 Workflow Engine (state machine voor klinische workflows)
├── C36 Full-Text Search Service (klinische notities doorzoeken)
├── C37 Caching Layer (AI responses, API responses, sessiedata)
├── C38 Scheduled Jobs Service (BullMQ repeatable jobs)
└── C39 Webhook System (outbound event webhooks)
LAAG 7: EXTERNE INTEGRATIES (nieuw)
├── C40 ZPM Facturatie Service (Zorgprestatiemodel)
├── C41 Vecozo Connector (voorbereiding)
└── C42 Zorgdomein Connector (voorbereiding)
LAAG 8: ANALYTICS (nieuw)
└── C43 BI Module (materialized views, data-export)
Prioriteit & fasering
| Component | Prioriteit | Bouwfase | Reden |
|---|---|---|---|
| C34 Notificatie | Must-have | Fase 2 (EPD core) | Overdracht, shifts, escalaties |
| C35 Workflow Engine | Must-have | Fase 2 | Intake, behandelplan, Wvggz |
| C36 Full-Text Search | Should-have | Fase 3 | Zoeken in rapportage, overdracht |
| C37 Caching | Should-have | Fase 2 | Performance, AI cost reductie |
| C38 Scheduled Jobs | Must-have | Fase 1 (fundament) | Audit cleanup, protocol sync, facturatie |
| C39 Webhook System | Should-have | Fase 4 (connectiviteit) | Connector events, externe integraties |
| C40 ZPM Facturatie | Must-have | Fase 3 | Inkomsten, wettelijk verplicht |
| C41 Vecozo | Later | Fase 4+ | Pas als EPD core stabiel is |
| C42 Zorgdomein | Later | Fase 4+ | Pas als EPD core stabiel is |
| C43 BI Module | Should-have | Fase 5 | Management rapportages |
C34 — Notificatie Service
Doel
Centraal systeem voor alle notificaties: in-app meldingen, email, en escalatieketens. Elke service in het systeem stuurt notificaties via dit component — geen directe email of push vanuit business logic.
Waarom nodig
- Overdracht: melden dat AI-samenvatting klaar is
- Shifts: herinnering aan overdracht, ongelezen rapportages
- Incidenten: escalatie naar teamleider als MIC-melding niet afgehandeld
- Wvggz: wettelijke termijnen die aflopen (dwangmaatregel verlenging)
- Medicatie: herinneringen aan medicatie-evaluaties
- Protocol Engine: nudges die niet in-app getoond konden worden
Interface
// lib/notifications/types.ts
type NotificationChannel = 'in_app' | 'email' | 'both';
type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
type NotificationCategory =
| 'overdracht'
| 'shift'
| 'incident'
| 'wvggz_termijn'
| 'medicatie'
| 'behandelplan'
| 'protocol_nudge'
| 'systeem'
| 'taak';
interface NotificationPayload {
recipientUserId: string;
tenantId: string;
category: NotificationCategory;
priority: NotificationPriority;
channel: NotificationChannel;
title: string; // NL, max 120 chars
body: string; // NL, max 500 chars
actionUrl?: string; // Deep link naar EPD pagina
relatedPatientId?: string; // Voor audit trail koppeling
relatedEntityType?: string; // 'report' | 'appointment' | 'treatment_plan'
relatedEntityId?: string;
metadata?: Record<string, unknown>;
expiresAt?: Date; // Automatisch opruimen
}
interface NotificationPreferences {
userId: string;
tenantId: string;
channelDefaults: Record<NotificationCategory, NotificationChannel>;
quietHours?: { start: string; end: string }; // "22:00" - "07:00"
emailDigest: 'immediate' | 'hourly' | 'daily' | 'off';
}
interface EscalationRule {
id: string;
tenantId: string;
category: NotificationCategory;
triggerCondition: string; // bv. "unread_after_minutes > 60"
escalateToRole: string; // bv. "teamleider"
escalateChannel: NotificationChannel;
maxEscalations: number;
}
// Service interface
interface NotificationService {
send(payload: NotificationPayload): Promise<{ notificationId: string }>;
sendBulk(payloads: NotificationPayload[]): Promise<{ sent: number; failed: number }>;
markAsRead(notificationId: string, userId: string): Promise<void>;
markAllAsRead(userId: string, category?: NotificationCategory): Promise<void>;
getUnread(userId: string, limit?: number): Promise<Notification[]>;
getUnreadCount(userId: string): Promise<Record<NotificationCategory, number>>;
updatePreferences(prefs: NotificationPreferences): Promise<void>;
registerEscalationRule(rule: EscalationRule): Promise<void>;
}
Database schema
-- Notificaties tabel
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
recipient_id UUID NOT NULL REFERENCES users(id),
category TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'normal',
channel TEXT NOT NULL DEFAULT 'in_app',
title TEXT NOT NULL,
body TEXT NOT NULL,
action_url TEXT,
related_patient_id UUID REFERENCES patients(id),
related_entity_type TEXT,
related_entity_id UUID,
metadata JSONB DEFAULT '{}',
read_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes voor snelle queries
CREATE INDEX idx_notifications_recipient_unread
ON notifications (recipient_id, read_at)
WHERE read_at IS NULL;
CREATE INDEX idx_notifications_tenant_category
ON notifications (tenant_id, category, created_at DESC);
-- RLS: gebruiker ziet alleen eigen notificaties
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY notifications_own ON notifications
FOR ALL USING (
tenant_id = current_setting('app.current_tenant_id')::UUID
AND recipient_id = current_setting('app.current_user_id')::UUID
);
-- Voorkeuren per gebruiker
CREATE TABLE notification_preferences (
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
channel_defaults JSONB NOT NULL DEFAULT '{}',
quiet_hours_start TIME,
quiet_hours_end TIME,
email_digest TEXT NOT NULL DEFAULT 'immediate',
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id)
);
-- Escalatie regels per tenant
CREATE TABLE escalation_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
category TEXT NOT NULL,
trigger_condition TEXT NOT NULL,
escalate_to_role TEXT NOT NULL,
escalate_channel TEXT NOT NULL DEFAULT 'both',
max_escalations INT NOT NULL DEFAULT 3,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C10 Protocol Engine | Nudges die niet in-app getoond worden → notificatie |
| C15 Rapportage Service | Nieuwe rapportage voor overdracht → notificatie aan team |
| C16 Overdracht Service | AI-samenvatting gereed → notificatie |
| C17 Agenda Service | Afspraakherinnering → notificatie |
| C22 Toestemming Service | Wvggz termijn nadert → escalatie notificatie |
| C25 RBAC + ABAC | Escalatie naar rol, niet naar specifieke persoon |
| C38 Scheduled Jobs | Periodieke digest emails, escalatie checks |
Technologiekeuze
- In-app: Server-Sent Events (SSE) vanuit Next.js API route — simpeler dan WebSockets, voldoende voor notificaties
- Email: Postal (self-hosted, D19) via BullMQ job queue
- Escalatie: BullMQ delayed jobs — check na X minuten of notificatie gelezen is
Trade-offs
| Keuze | Alternatief | Rationale |
|---|---|---|
| SSE voor real-time | WebSockets | Eenvoudiger, minder infra, unidirectioneel is voldoende |
| Escalatie via BullMQ | Aparte escalatie service | Hergebruik bestaande infra (C38), KISS |
| Notificatie in PostgreSQL | Aparte message store (Redis) | Eén bron van waarheid, auditbaar, RLS |
C35 — Workflow Engine (State Machine)
Doel
Generieke state machine die klinische workflows bestuurt: intake-trajecten, behandelplannen, Wvggz-procedures, MIC-meldingen. Dwingt af dat stappen in de juiste volgorde gebeuren, door de juiste rollen, binnen wettelijke termijnen.
Waarom nodig
- Intake: Aanmelding → Triage → Diagnostiek → Behandelplan → Start behandeling
- Wvggz: Verzoekschrift → Beoordeling → Machtiging → Uitvoering → Evaluatie (wettelijke termijnen!)
- MIC-melding: Registratie → Analyse → Maatregelen → Evaluatie → Afsluiting
- Behandelplan: Concept → Review → Akkoord patiënt → Actief → Evaluatie
- Ontslag: Voorbereiding → Nazorgplan → Overdracht → Ontslag
Interface
// lib/workflow/types.ts
interface WorkflowDefinition {
id: string;
name: string; // bv. "intake_ggz"
version: number;
tenantId: string | null; // null = systeembreed
states: WorkflowState[];
transitions: WorkflowTransition[];
initialState: string;
terminalStates: string[];
}
interface WorkflowState {
name: string; // bv. "triage", "diagnostiek"
label: string; // NL label: "Triage"
requiredRole?: string; // Welke rol mag in deze state werken
maxDurationDays?: number; // Wettelijke of interne deadline
onEnter?: WorkflowAction[]; // Acties bij betreden state
onExit?: WorkflowAction[]; // Acties bij verlaten state
requiredFields?: string[]; // Welke velden ingevuld moeten zijn
}
interface WorkflowTransition {
from: string;
to: string;
trigger: string; // bv. "approve", "reject", "timeout"
requiredRole?: string; // Wie mag deze transitie uitvoeren
guard?: string; // Conditie (bv. "all_required_fields_filled")
actions?: WorkflowAction[]; // Acties bij transitie
}
interface WorkflowAction {
type: 'notify' | 'create_task' | 'update_field' | 'escalate' | 'audit_log';
config: Record<string, unknown>;
}
interface WorkflowInstance {
id: string;
workflowDefinitionId: string;
tenantId: string;
entityType: string; // 'intake' | 'behandelplan' | 'wvggz' | 'mic_melding'
entityId: string; // ID van het gerelateerde object
currentState: string;
stateHistory: StateTransitionRecord[];
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
}
interface StateTransitionRecord {
fromState: string;
toState: string;
trigger: string;
userId: string;
timestamp: Date;
comment?: string;
metadata?: Record<string, unknown>;
}
// Service interface
interface WorkflowService {
// Definitie beheer
registerDefinition(def: WorkflowDefinition): Promise<void>;
getDefinition(name: string, version?: number): Promise<WorkflowDefinition>;
// Instance beheer
startWorkflow(params: {
definitionName: string;
entityType: string;
entityId: string;
tenantId: string;
initiatorUserId: string;
}): Promise<WorkflowInstance>;
transition(params: {
instanceId: string;
trigger: string;
userId: string;
comment?: string;
}): Promise<WorkflowInstance>;
getCurrentState(instanceId: string): Promise<WorkflowInstance>;
getAvailableTransitions(instanceId: string, userId: string): Promise<WorkflowTransition[]>;
getInstancesByEntity(entityType: string, entityId: string): Promise<WorkflowInstance[]>;
// Deadline monitoring
getOverdueInstances(tenantId: string): Promise<WorkflowInstance[]>;
}
Database schema
-- Workflow definities (versioned)
CREATE TABLE workflow_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
version INT NOT NULL DEFAULT 1,
tenant_id UUID REFERENCES tenants(id), -- NULL = systeembreed
definition JSONB NOT NULL, -- Volledige WorkflowDefinition
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(name, version, tenant_id)
);
-- Workflow instances
CREATE TABLE workflow_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_def_id UUID NOT NULL REFERENCES workflow_definitions(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
current_state TEXT NOT NULL,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_workflow_instances_entity
ON workflow_instances (entity_type, entity_id);
CREATE INDEX idx_workflow_instances_state
ON workflow_instances (tenant_id, current_state)
WHERE completed_at IS NULL;
-- State transitie historie (immutable)
CREATE TABLE workflow_transitions_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES workflow_instances(id),
from_state TEXT NOT NULL,
to_state TEXT NOT NULL,
trigger TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
comment TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Immutable: geen UPDATE of DELETE
CREATE RULE workflow_transitions_no_update AS
ON UPDATE TO workflow_transitions_log DO INSTEAD NOTHING;
CREATE RULE workflow_transitions_no_delete AS
ON DELETE TO workflow_transitions_log DO INSTEAD NOTHING;
-- RLS
ALTER TABLE workflow_instances ENABLE ROW LEVEL SECURITY;
ALTER TABLE workflow_transitions_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY workflow_instances_tenant ON workflow_instances
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY workflow_transitions_tenant ON workflow_transitions_log
FOR ALL USING (
instance_id IN (
SELECT id FROM workflow_instances
WHERE tenant_id = current_setting('app.current_tenant_id')::UUID
)
);
Voorgedefinieerde workflows
// lib/workflow/definitions/intake-ggz.ts
const intakeGGZ: WorkflowDefinition = {
id: 'wf-intake-ggz',
name: 'intake_ggz',
version: 1,
tenantId: null,
initialState: 'aangemeld',
terminalStates: ['gestart', 'afgewezen'],
states: [
{ name: 'aangemeld', label: 'Aangemeld', maxDurationDays: 5,
onEnter: [{ type: 'notify', config: { category: 'taak', role: 'triagist' } }] },
{ name: 'triage', label: 'Triage', requiredRole: 'triagist', maxDurationDays: 3 },
{ name: 'diagnostiek', label: 'Diagnostiek', requiredRole: 'behandelaar', maxDurationDays: 14 },
{ name: 'behandelplan', label: 'Behandelplan opstellen', requiredRole: 'behandelaar', maxDurationDays: 7,
requiredFields: ['diagnose', 'behandeldoelen'] },
{ name: 'akkoord_patient', label: 'Akkoord patiënt', maxDurationDays: 5 },
{ name: 'gestart', label: 'Behandeling gestart' },
{ name: 'afgewezen', label: 'Afgewezen (doorverwijzing)' },
],
transitions: [
{ from: 'aangemeld', to: 'triage', trigger: 'start_triage', requiredRole: 'triagist' },
{ from: 'triage', to: 'diagnostiek', trigger: 'accept', requiredRole: 'triagist' },
{ from: 'triage', to: 'afgewezen', trigger: 'reject', requiredRole: 'triagist',
actions: [{ type: 'notify', config: { category: 'taak', message: 'Doorverwijsbrief opstellen' } }] },
{ from: 'diagnostiek', to: 'behandelplan', trigger: 'diagnose_complete', requiredRole: 'behandelaar' },
{ from: 'behandelplan', to: 'akkoord_patient', trigger: 'submit_plan', guard: 'all_required_fields_filled' },
{ from: 'akkoord_patient', to: 'gestart', trigger: 'patient_agrees' },
{ from: 'akkoord_patient', to: 'behandelplan', trigger: 'patient_disagrees' },
],
};
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C10 Protocol Engine | Nudge kan workflow transitie suggereren |
| C15 Rapportage Service | MIC-melding start mic_workflow instance |
| C18 Behandelplan Service | Behandelplan lifecycle via workflow |
| C20 Intake Service | Intake trajectory via workflow |
| C22 Toestemming Service | Wvggz-procedure als workflow met wettelijke deadlines |
| C25 RBAC + ABAC | requiredRole in transitions → RBAC check |
| C26 Audit Trail | Elke transitie → audit log entry |
| C34 Notificatie | onEnter, onExit, transitie acties → notificaties |
| C38 Scheduled Jobs | Deadline monitoring: check dagelijks op maxDurationDays overschrijding |
Technologiekeuze
- Geen externe library: State machine logica is ~200 regels TypeScript. XState is overkill voor server-side workflow.
- Definities in code + database: Systeem-workflows in code (version controlled), tenant-specifieke aanpassingen in database.
- Transitie log immutable: Net als audit trail — compliance vereist onveranderbare historie.
C36 — Full-Text Search Service
Doel
Zoeken door klinische notities, rapportages en patiëntgegevens op basis van vrije tekst. Ondersteunt het intent "zoeken" (C06/C07) en de traditionele EPD zoekbalk.
Waarom nodig
- Zorgverlener zoekt: "alle rapportages over agressie bij Jan Janssen afgelopen maand"
- Compliance officer zoekt: "alle incidenten met fixatie in 2025"
- Intent systeem:
zoekenintent moet rapportages kunnen doorzoeken
Interface
// lib/search/types.ts
interface SearchQuery {
tenantId: string;
query: string; // Vrije tekst, NL
filters?: {
entityTypes?: ('report' | 'patient' | 'appointment' | 'treatment_plan')[];
patientId?: string;
dateRange?: { from: Date; to: Date };
reportCategories?: string[];
authorId?: string;
departmentId?: string;
};
pagination?: { offset: number; limit: number };
highlight?: boolean; // Markeer matches in resultaat
}
interface SearchResult {
entityType: string;
entityId: string;
score: number;
title: string;
snippet: string; // Fragment met context
highlights?: string[]; // Gemarkeerde matches
metadata: {
patientName?: string;
authorName?: string;
createdAt: Date;
category?: string;
};
}
interface SearchResponse {
results: SearchResult[];
total: number;
took: number; // milliseconds
query: string;
}
// Service interface
interface SearchService {
search(query: SearchQuery): Promise<SearchResponse>;
indexDocument(params: {
entityType: string;
entityId: string;
tenantId: string;
content: string;
metadata: Record<string, unknown>;
}): Promise<void>;
removeDocument(entityType: string, entityId: string): Promise<void>;
reindexAll(tenantId: string): Promise<{ indexed: number }>;
}
Database schema
-- Full-text search configuratie voor Nederlands
CREATE TEXT SEARCH CONFIGURATION dutch_medical (COPY = dutch);
-- Medische synoniemen dictionary (uitbreidbaar)
-- In productie: custom dictionary file met medische termen
-- ALTER TEXT SEARCH CONFIGURATION dutch_medical
-- ALTER MAPPING FOR asciiword WITH dutch_medical_syn, dutch_stem;
-- Search index op reports
ALTER TABLE reports ADD COLUMN IF NOT EXISTS
search_vector TSVECTOR
GENERATED ALWAYS AS (
setweight(to_tsvector('dutch', coalesce(title, '')), 'A') ||
setweight(to_tsvector('dutch', coalesce(content, '')), 'B') ||
setweight(to_tsvector('dutch', coalesce(
structured_data->>'category', ''
)), 'C')
) STORED;
CREATE INDEX idx_reports_search ON reports USING GIN (search_vector);
-- Search index op patients
ALTER TABLE patients ADD COLUMN IF NOT EXISTS
search_vector TSVECTOR
GENERATED ALWAYS AS (
setweight(to_tsvector('dutch', coalesce(first_name, '')), 'A') ||
setweight(to_tsvector('dutch', coalesce(last_name, '')), 'A') ||
setweight(to_tsvector('dutch', coalesce(bsn, '')), 'A')
) STORED;
CREATE INDEX idx_patients_search ON patients USING GIN (search_vector);
-- Zoekquery met ranking
-- Voorbeeld: zoek "agressie medicatie" in rapportages van een patiënt
-- SELECT id, title,
-- ts_rank_cd(search_vector, query) AS rank,
-- ts_headline('dutch', content, query, 'MaxWords=35, MinWords=15') AS snippet
-- FROM reports,
-- plainto_tsquery('dutch', 'agressie medicatie') query
-- WHERE search_vector @@ query
-- AND patient_id = $1
-- AND tenant_id = current_setting('app.current_tenant_id')::UUID
-- AND deleted_at IS NULL
-- ORDER BY rank DESC
-- LIMIT 20;
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C06 Reflex / C07 Orchestrator | zoeken intent → SearchService.search() |
| C09 Action System | Search resultaten als block in canvas |
| C15 Rapportage Service | Bij create/update → automatisch geïndexeerd (GENERATED column) |
| C21 Patiënt Service | Patiënt zoeken → search_vector op patients |
| C24 Multi-tenancy | RLS op search queries: tenant_id filter |
Technologiekeuze
- PostgreSQL tsvector (geen Elasticsearch): Eenvoudiger stack, geen extra service, voldoende voor GGZ-schaal (~100k-1M documenten)
- GENERATED ALWAYS AS STORED: Automatische index-update bij write, geen aparte indexer nodig
- Nederlandse stemmer: Ingebouwd in PostgreSQL, uitbreidbaar met medische synoniemen dictionary
Trade-offs
| Keuze | Alternatief | Rationale |
|---|---|---|
| PostgreSQL tsvector | Elasticsearch, Meilisearch, Typesense | Geen extra service, voldoende voor verwacht volume |
| GENERATED column | Trigger-based indexing | Eenvoudiger, altijd in sync, geen race conditions |
ts_rank_cd |
Custom scoring | Standaard PostgreSQL, goed genoeg voor eerste iteratie |
Schaalgrens
PostgreSQL full-text search werkt goed tot ~5M documenten. Als het systeem voorbij die grens groeit, kan Meilisearch (self-hosted, Rust, <100MB RAM) als drop-in replacement dienen zonder interface-wijzigingen.
C37 — Caching Layer
Doel
Caching van dure operaties: AI-responses, API-responses, sessiedata, en veelgevraagde queries. Vermindert latency, kosten (minder AI API calls), en database load.
Waarom nodig
- AI kosten: Dezelfde overdracht-samenvatting wordt meerdere keren bekeken → cache
- Performance: Patient zoekresultaten, dashboard aggregaties, agenda views
- Protocol Engine: RAG results cachen zodat dezelfde protocol-query niet steeds opnieuw draait
- Sessiedata: Active patient context, recente navigatie
Interface
// lib/cache/types.ts
type CacheNamespace =
| 'ai:summary' // Overdracht AI samenvattingen
| 'ai:classification' // Intent classificatie resultaten
| 'ai:rag' // RAG pipeline resultaten
| 'api:patients' // Patient zoekresultaten
| 'api:agenda' // Agenda views
| 'api:dashboard' // Dashboard aggregaties
| 'session:context' // Active patient, recente acties
| 'config:protocols'; // Protocol definities
interface CacheOptions {
ttl: number; // Seconds
namespace: CacheNamespace;
tags?: string[]; // Voor gerichte invalidatie
}
interface CacheService {
get<T>(key: string, namespace: CacheNamespace): Promise<T | null>;
set<T>(key: string, value: T, options: CacheOptions): Promise<void>;
delete(key: string, namespace: CacheNamespace): Promise<void>;
invalidateByTag(tag: string): Promise<number>; // Returns count
invalidateNamespace(namespace: CacheNamespace): Promise<void>;
// Convenience: get-or-set pattern
getOrSet<T>(
key: string,
factory: () => Promise<T>,
options: CacheOptions
): Promise<T>;
// Stats
getStats(): Promise<{
hitRate: number;
memoryUsage: number;
keyCount: number;
}>;
}
TTL strategie
| Namespace | TTL | Invalidatie |
|---|---|---|
ai:summary |
30 min | Bij nieuwe rapportage voor die patiënt |
ai:classification |
5 min | Zelden — zelfde input = zelfde output |
ai:rag |
60 min | Bij protocol update |
api:patients |
10 min | Bij patient update |
api:agenda |
5 min | Bij appointment create/update/delete |
api:dashboard |
15 min | Periodiek (C38 scheduled job) |
session:context |
60 min | Bij navigatie-actie |
config:protocols |
24 uur | Bij protocol wijziging |
Implementatie
// lib/cache/redis-cache.ts
import Redis from 'ioredis';
const NAMESPACE_PREFIX: Record<CacheNamespace, string> = {
'ai:summary': 'ai:sum:',
'ai:classification': 'ai:cls:',
'ai:rag': 'ai:rag:',
'api:patients': 'api:pat:',
'api:agenda': 'api:agd:',
'api:dashboard': 'api:dsh:',
'session:context': 'ses:ctx:',
'config:protocols': 'cfg:prt:',
};
// Cache key inclusief tenant voor isolatie
function buildKey(namespace: CacheNamespace, tenantId: string, key: string): string {
return `${NAMESPACE_PREFIX[namespace]}${tenantId}:${key}`;
}
// Tag-based invalidatie via Redis Sets
// Set "tag:{tagName}" bevat alle keys met die tag
// Bij invalidateByTag: SMEMBERS + DEL pipeline
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C07 Orchestrator | Cache classificatie resultaten (zelfde input → zelfde output) |
| C12 RAG Pipeline | Cache vector search resultaten per query hash |
| C16 Overdracht Service | Cache AI-samenvattingen per patiënt + shift |
| C24 Multi-tenancy | Cache keys bevatten tenant_id — volledige isolatie |
| C31 Circuit Breaker | Bij AI service down → serve from cache (stale-while-revalidate) |
Technologiekeuze
- Redis (al in stack voor BullMQ, D18): Hergebruik bestaande Redis instance
- ioredis: Standaard Node.js Redis client, pipeline support, cluster-ready
Geen gevoelige data in cache
BSN, diagnoses en andere PII worden nooit direct gecached. Alleen:
- Hashed keys met verwijzing naar encrypted database records
- AI-gegenereerde samenvattingen (geen ruwe patiëntdata)
- Aggregaties en counts
C38 — Scheduled Jobs Service
Doel
Betrouwbare uitvoering van periodieke en geplande taken: audit trail cleanup, protocol synchronisatie, facturatie batches, deadline monitoring, email digests.
Waarom nodig
- Audit trail: Partities aanmaken, oude partities archiveren
- Workflow deadlines: Dagelijks checken of workflows hun
maxDurationDaysoverschrijden - Notificatie digests: Dagelijkse email samenvattingen
- ZPM facturatie: Maandelijkse facturatie batch
- Protocol sync: Periodiek nieuwe protocollen ophalen/indexeren
- Caching: Periodiek dashboard aggregaties vernieuwen
Interface
// lib/jobs/types.ts
interface JobDefinition {
name: string; // Unieke naam: "audit.partition.create"
handler: (payload: unknown) => Promise<void>;
schedule?: string; // Cron syntax: "0 2 * * *" (02:00 dagelijks)
retries?: number; // Default: 3
backoff?: { type: 'exponential' | 'fixed'; delay: number };
timeout?: number; // Milliseconds
concurrency?: number; // Max parallel workers
}
interface ScheduledJobsService {
register(definition: JobDefinition): void;
enqueue(jobName: string, payload?: unknown, options?: {
delay?: number; // Milliseconds
priority?: number; // 1 (highest) - 10 (lowest)
tenantId?: string;
}): Promise<string>; // Returns job ID
getStatus(jobId: string): Promise<JobStatus>;
getFailedJobs(limit?: number): Promise<FailedJob[]>;
retryFailed(jobId: string): Promise<void>;
getMetrics(): Promise<JobMetrics>;
}
interface JobMetrics {
active: number;
waiting: number;
completed: number;
failed: number;
delayed: number;
}
Geplande jobs
// lib/jobs/definitions.ts
const scheduledJobs: JobDefinition[] = [
{
name: 'audit.partition.create',
schedule: '0 0 25 * *', // 25e van de maand, partitie voor volgende maand
handler: createNextAuditPartition,
retries: 3,
},
{
name: 'workflow.deadline.check',
schedule: '0 8 * * *', // Dagelijks 08:00
handler: checkWorkflowDeadlines, // Overschrijdingen → escalatie notificatie
retries: 2,
},
{
name: 'notifications.digest',
schedule: '0 7 * * *', // Dagelijks 07:00
handler: sendDailyDigests, // Email digest aan users met die voorkeur
retries: 2,
},
{
name: 'zpm.facturatie.batch',
schedule: '0 2 1 * *', // 1e van de maand, 02:00
handler: runZPMFacturatieBatch, // Maandelijkse facturatie
retries: 3,
timeout: 600_000, // 10 minuten
},
{
name: 'protocol.sync',
schedule: '0 3 * * 0', // Zondag 03:00
handler: syncProtocolDocuments, // RAG pipeline re-index
retries: 2,
timeout: 300_000,
},
{
name: 'cache.dashboard.refresh',
schedule: '*/15 * * * *', // Elke 15 minuten
handler: refreshDashboardCache,
retries: 1,
},
{
name: 'notifications.escalation.check',
schedule: '*/5 * * * *', // Elke 5 minuten
handler: checkEscalations, // Ongelezen urgente notificaties → escaleer
retries: 1,
},
];
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C26 Audit Trail | Partitie management, archivering |
| C34 Notificatie | Digest emails, escalatie checks |
| C35 Workflow Engine | Deadline monitoring |
| C37 Caching | Dashboard cache verversing |
| C40 ZPM Facturatie | Maandelijkse batch |
| C12 RAG Pipeline | Protocol re-indexering |
Technologiekeuze
- BullMQ (al gekozen, D18): Betrouwbare job queue met Redis backing, cron support, retry logic, dashboard (Bull Board)
- Bull Board: Web UI voor job monitoring — beschikbaar voor admin users
C39 — Webhook System
Doel
Outbound webhooks voor externe integraties: externe systemen kunnen zich abonneren op events in het EPD platform. Essentieel voor de connector-architectuur (D17) en toekomstige integraties.
Waarom nodig
- Connector API: Extern EPD wil weten wanneer er een nieuw rapport is aangemaakt
- Externe systemen: Facturatiesysteem, CRM, onderzoeksplatform willen events ontvangen
- Toekomstige integraties: Vecozo, Zorgdomein, BI-tools
Interface
// lib/webhooks/types.ts
type WebhookEvent =
| 'report.created'
| 'report.updated'
| 'report.deleted'
| 'patient.admitted'
| 'patient.discharged'
| 'appointment.created'
| 'appointment.updated'
| 'appointment.cancelled'
| 'treatment_plan.activated'
| 'treatment_plan.updated'
| 'workflow.state_changed'
| 'incident.reported'
| 'wvggz.status_changed';
interface WebhookRegistration {
id: string;
tenantId: string;
url: string; // HTTPS endpoint
events: WebhookEvent[]; // Welke events
secret: string; // HMAC-SHA256 signing secret
active: boolean;
description?: string;
createdBy: string;
headers?: Record<string, string>; // Extra headers (auth tokens etc.)
retryPolicy?: {
maxRetries: number; // Default: 5
backoffMs: number; // Default: 1000, exponential
};
}
interface WebhookPayload {
id: string; // Unique event ID
event: WebhookEvent;
tenantId: string;
timestamp: string; // ISO 8601
data: Record<string, unknown>; // Event-specifieke data
// NOOIT PII in webhook payload — alleen IDs en metadata
}
interface WebhookDeliveryLog {
id: string;
webhookId: string;
event: WebhookEvent;
payload: WebhookPayload;
responseStatus: number | null;
responseBody?: string;
attempt: number;
deliveredAt: Date | null;
nextRetryAt: Date | null;
error?: string;
}
// Service interface
interface WebhookService {
register(registration: Omit<WebhookRegistration, 'id' | 'secret'>): Promise<WebhookRegistration>;
update(id: string, updates: Partial<WebhookRegistration>): Promise<WebhookRegistration>;
delete(id: string): Promise<void>;
list(tenantId: string): Promise<WebhookRegistration[]>;
// Event dispatching (intern, aangeroepen door andere services)
dispatch(event: WebhookEvent, tenantId: string, data: Record<string, unknown>): Promise<void>;
// Delivery monitoring
getDeliveryLog(webhookId: string, limit?: number): Promise<WebhookDeliveryLog[]>;
retryDelivery(deliveryLogId: string): Promise<void>;
}
Database schema
-- Webhook registraties
CREATE TABLE webhook_registrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
url TEXT NOT NULL,
events TEXT[] NOT NULL,
secret TEXT NOT NULL, -- HMAC signing secret (encrypted at rest)
active BOOLEAN DEFAULT TRUE,
description TEXT,
headers JSONB DEFAULT '{}',
retry_max INT DEFAULT 5,
retry_backoff INT DEFAULT 1000,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Delivery log (voor monitoring en retry)
CREATE TABLE webhook_delivery_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES webhook_registrations(id),
event TEXT NOT NULL,
payload JSONB NOT NULL,
response_status INT,
response_body TEXT,
attempt INT NOT NULL DEFAULT 1,
delivered_at TIMESTAMPTZ,
next_retry_at TIMESTAMPTZ,
error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Partitie delivery log per maand (groeit snel)
-- CREATE TABLE webhook_delivery_log_2026_02 PARTITION OF webhook_delivery_log
-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE INDEX idx_webhook_delivery_pending
ON webhook_delivery_log (next_retry_at)
WHERE delivered_at IS NULL AND next_retry_at IS NOT NULL;
-- RLS
ALTER TABLE webhook_registrations ENABLE ROW LEVEL SECURITY;
ALTER TABLE webhook_delivery_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY webhook_reg_tenant ON webhook_registrations
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
Beveiliging
// Webhook signing — HMAC-SHA256
import crypto from 'node:crypto';
function signPayload(payload: WebhookPayload, secret: string): string {
const body = JSON.stringify(payload);
return crypto.createHmac('sha256', secret).update(body).digest('hex');
}
// Headers bij elke delivery:
// X-Webhook-Id: <registration id>
// X-Webhook-Event: report.created
// X-Webhook-Signature: sha256=<hmac>
// X-Webhook-Timestamp: <unix timestamp>
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C15 Rapportage Service | report.created/updated/deleted → dispatch |
| C17 Agenda Service | appointment.* → dispatch |
| C18 Behandelplan Service | treatment_plan.* → dispatch |
| C35 Workflow Engine | workflow.state_changed → dispatch |
| C38 Scheduled Jobs | Retry queue voor gefaalde deliveries |
| C40-C42 Externe integraties | Ontvangen events via webhook registraties |
Belangrijk: geen PII in payloads
Webhook payloads bevatten alleen IDs en metadata, nooit PII:
// GOED:
{ event: 'report.created', data: { reportId: 'uuid', patientId: 'uuid', type: 'observatie' } }
// FOUT (nooit):
{ event: 'report.created', data: { patientName: 'Jan Janssen', bsn: '123456789', content: '...' } }
De ontvanger haalt volledige data op via de API met eigen authenticatie.
C40 — ZPM Facturatie Service
Doel
Voorbereiding op Zorgprestatiemodel (ZPM) facturatie: registratie van zorgprestaties, koppeling aan DBC-trajecten, en generatie van facturatiebatches. ZPM is sinds 2022 het bekostigingssysteem voor GGZ.
Waarom nodig
- Wettelijk verplicht: Elke GGZ-instelling moet factureren volgens ZPM
- Zorgprestaties: Elke behandelactiviteit (consult, groepssessie, diagnostiek) → prestatiecode
- DBC-trajecten: Patiënt heeft een lopend DBC (Diagnose Behandel Combinatie) traject
- Facturatiebatch: Maandelijks → declaratiebericht naar zorgverzekeraar (via Vecozo)
Interface
// lib/facturatie/types.ts
// NZa prestatiecode structuur
interface ZorgPrestatie {
id: string;
tenantId: string;
patientId: string;
dbcTrajectId: string;
prestatieCode: string; // NZa code, bv. "OVP-0001"
prestatieType: 'consult' | 'groep' | 'diagnostiek' | 'verblijf' | 'crisis' | 'overig';
beroep: string; // BIG-registratie beroep behandelaar
duur: number; // Minuten (voor directe tijd)
indirecteTijd?: number; // Minuten indirect
datum: Date;
behandelaarId: string;
appointmentId?: string; // Koppeling aan agenda
reportId?: string; // Koppeling aan rapportage
status: 'concept' | 'definitief' | 'gedeclareerd' | 'betaald' | 'afgewezen';
metadata?: Record<string, unknown>;
}
interface DBCTraject {
id: string;
tenantId: string;
patientId: string;
diagnoseCode: string; // DSM-5 code
zorgtype: string; // Initieel / vervolg
startDatum: Date;
eindDatum?: Date;
status: 'open' | 'gesloten' | 'afgesloten_declaratie';
verzekeringId: string; // Verwijzing naar verzekeringgegevens
}
interface DeclaratieBatch {
id: string;
tenantId: string;
periode: string; // "2026-02"
prestaties: string[]; // Prestatie IDs
totaalBedrag: number; // Euro's
status: 'concept' | 'gecontroleerd' | 'verstuurd' | 'verwerkt' | 'deels_afgewezen';
vecozo_referentie?: string; // Na verzending
gegenereerd_op: Date;
verstuurd_op?: Date;
}
// Service interface
interface FacturatieService {
// Prestatie registratie
registreerPrestatie(prestatie: Omit<ZorgPrestatie, 'id' | 'status'>): Promise<ZorgPrestatie>;
koppelAanAfspraak(prestatieId: string, appointmentId: string): Promise<void>;
koppelAanRapportage(prestatieId: string, reportId: string): Promise<void>;
// DBC beheer
openTraject(traject: Omit<DBCTraject, 'id' | 'status'>): Promise<DBCTraject>;
sluitTraject(trajectId: string): Promise<void>;
// Facturatie
genereerBatch(tenantId: string, periode: string): Promise<DeclaratieBatch>;
controleerBatch(batchId: string): Promise<ValidationResult[]>; // Controle regels
markeerVerstuurd(batchId: string, vecozoRef: string): Promise<void>;
// Overzichten
getOpenPrestaties(tenantId: string, filters?: object): Promise<ZorgPrestatie[]>;
getTrajecten(patientId: string): Promise<DBCTraject[]>;
getOmzetOverzicht(tenantId: string, periode: string): Promise<OmzetOverzicht>;
}
interface ValidationResult {
prestatieId: string;
valid: boolean;
errors: string[]; // NL foutmeldingen
warnings: string[];
}
interface OmzetOverzicht {
periode: string;
totaalGedeclareerd: number;
totaalBetaald: number;
totaalAfgewezen: number;
totaalOpen: number;
perBeroepsgroep: Record<string, number>;
perZorgtype: Record<string, number>;
}
Database schema
-- DBC trajecten
CREATE TABLE dbc_trajecten (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
patient_id UUID NOT NULL REFERENCES patients(id),
diagnose_code TEXT NOT NULL,
zorgtype TEXT NOT NULL DEFAULT 'initieel',
start_datum DATE NOT NULL,
eind_datum DATE,
status TEXT NOT NULL DEFAULT 'open',
verzekering_id UUID, -- Toekomstig: verzekeringsgegevens tabel
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Zorgprestaties
CREATE TABLE zorgprestaties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
patient_id UUID NOT NULL REFERENCES patients(id),
dbc_traject_id UUID NOT NULL REFERENCES dbc_trajecten(id),
prestatie_code TEXT NOT NULL,
prestatie_type TEXT NOT NULL,
beroep TEXT NOT NULL,
duur_minuten INT NOT NULL,
indirecte_tijd INT DEFAULT 0,
datum DATE NOT NULL,
behandelaar_id UUID NOT NULL REFERENCES users(id),
appointment_id UUID REFERENCES appointments(id),
report_id UUID REFERENCES reports(id),
status TEXT NOT NULL DEFAULT 'concept',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_zorgprestaties_patient ON zorgprestaties (patient_id, datum DESC);
CREATE INDEX idx_zorgprestaties_status ON zorgprestaties (tenant_id, status);
CREATE INDEX idx_zorgprestaties_traject ON zorgprestaties (dbc_traject_id);
-- Declaratie batches
CREATE TABLE declaratie_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
periode TEXT NOT NULL, -- "2026-02"
prestatie_ids UUID[] NOT NULL,
totaal_bedrag DECIMAL(10,2),
status TEXT NOT NULL DEFAULT 'concept',
vecozo_ref TEXT,
gegenereerd_op TIMESTAMPTZ DEFAULT NOW(),
verstuurd_op TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
-- RLS
ALTER TABLE dbc_trajecten ENABLE ROW LEVEL SECURITY;
ALTER TABLE zorgprestaties ENABLE ROW LEVEL SECURITY;
ALTER TABLE declaratie_batches ENABLE ROW LEVEL SECURITY;
CREATE POLICY dbc_tenant ON dbc_trajecten
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY zorgprestaties_tenant ON zorgprestaties
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY declaratie_tenant ON declaratie_batches
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C17 Agenda Service | Afspraak → automatisch zorgprestatie concept aanmaken |
| C15 Rapportage Service | Rapport met contactmoment → koppelen aan prestatie |
| C25 RBAC + ABAC | Alleen financieel medewerker mag batches genereren |
| C26 Audit Trail | Elke facturatie-actie → audit log |
| C35 Workflow Engine | Declaratie workflow: concept → controle → verzending |
| C38 Scheduled Jobs | Maandelijkse batch generatie |
| C41 Vecozo | Declaratiebatch versturen naar Vecozo (toekomstig) |
Controleregels
// lib/facturatie/validatie.ts
// Voorbeelden van validatieregels die bij batch-controle draaien:
const validatieRegels = [
// Geen prestatie zonder open DBC traject
'prestatie.datum BETWEEN dbc.start_datum AND COALESCE(dbc.eind_datum, NOW())',
// Maximaal 12 uur directe tijd per dag per behandelaar
'SUM(duur_minuten) per behandelaar per dag <= 720',
// Prestatiecode moet geldig zijn voor beroep
'prestatie_code IN (valid_codes_for_beroep)',
// Geen dubbele prestatie (zelfde patient, dag, code, behandelaar)
'UNIQUE(patient_id, datum, prestatie_code, behandelaar_id)',
];
C41 — Vecozo Connector (voorbereiding)
Doel
Interface-voorbereiding voor koppeling met Vecozo: het landelijke communicatiepunt voor declaraties, COV-checks (Controle Op Verzekeringsrecht), en machtigingen in de zorg.
Status: alleen interface — geen implementatie
Vecozo-koppeling vereist een formeel aansluittraject (UZI-certificaat, testomgeving, acceptatie). Dit component definieert alleen de interfaces zodat C40 (ZPM Facturatie) er straks naadloos op aan kan sluiten.
Interface
// lib/integrations/vecozo/types.ts
interface VecozoConnector {
// COV-check: is patiënt verzekerd?
checkVerzekering(params: {
bsn: string;
peildatum: Date;
}): Promise<VerzekeringStatus>;
// Declaratie versturen
submitDeclaratie(params: {
batchId: string;
declaratieXml: string; // Vektis EI-standaard
}): Promise<DeclaratieResponse>;
// Declaratie status opvragen
getDeclaratieStatus(vecozoRef: string): Promise<DeclaratieStatusResponse>;
// Machtiging aanvragen (voor bepaalde zorgtypen)
requestMachtiging(params: {
patientBsn: string;
zorgtype: string;
motivatie: string;
}): Promise<MachtigingResponse>;
// Health check
isAvailable(): Promise<boolean>;
}
interface VerzekeringStatus {
verzekerd: boolean;
verzekeraar?: string;
uzovi?: string; // UZOVI-code verzekeraar
polisnummer?: string;
ingangsDatum?: Date;
eindDatum?: Date;
}
interface DeclaratieResponse {
accepted: boolean;
vecozoReferentie: string;
errors?: string[];
}
interface DeclaratieStatusResponse {
status: 'ontvangen' | 'in_behandeling' | 'goedgekeurd' | 'deels_afgewezen' | 'afgewezen';
details?: string;
betaalDatum?: Date;
}
interface MachtigingResponse {
status: 'toegekend' | 'afgewezen' | 'in_behandeling';
machtigingNummer?: string;
geldigTot?: Date;
}
Vereisten voor aansluiting (niet nu, wel documenteren)
- UZI-certificaat: Nodig voor mTLS communicatie met Vecozo
- AGB-code: Instelling moet geregistreerd zijn
- Testomgeving: Vecozo biedt een acceptatieomgeving
- Vektis standaarden: Declaratieberichten in EI-standaard XML formaat
- BSN-verificatie: Koppeling met BRP via Vecozo
Placeholder implementatie
// lib/integrations/vecozo/mock-connector.ts
export class MockVecozoConnector implements VecozoConnector {
async checkVerzekering(params: { bsn: string; peildatum: Date }): Promise<VerzekeringStatus> {
console.warn('[Vecozo] Mock connector — niet verbonden met Vecozo');
return {
verzekerd: true,
verzekeraar: 'Mock Verzekeraar',
uzovi: '0000',
};
}
async submitDeclaratie(): Promise<DeclaratieResponse> {
throw new Error('[Vecozo] Declaratie versturen niet beschikbaar — Vecozo connector niet geconfigureerd');
}
// ... etc
}
C42 — Zorgdomein Connector (voorbereiding)
Doel
Interface-voorbereiding voor koppeling met Zorgdomein: het landelijke platform voor elektronische verwijzingen van huisarts naar GGZ.
Status: alleen interface — geen implementatie
Net als Vecozo vereist Zorgdomein een formeel aansluittraject. Dit component definieert de interfaces.
Interface
// lib/integrations/zorgdomein/types.ts
interface ZorgdomeinConnector {
// Nieuwe verwijzingen ophalen
getNewReferrals(since: Date): Promise<ZorgdomeinVerwijzing[]>;
// Verwijzing accepteren/afwijzen
acceptReferral(verwijzingId: string): Promise<void>;
rejectReferral(verwijzingId: string, reden: string): Promise<void>;
// Status terugkoppelen
updateReferralStatus(verwijzingId: string, status: string): Promise<void>;
// Health check
isAvailable(): Promise<boolean>;
}
interface ZorgdomeinVerwijzing {
id: string;
verwijzerAgbCode: string; // Huisarts AGB-code
verwijzerNaam: string;
patientBsn: string;
patientNaam: string;
verwijsDatum: Date;
urgentie: 'normaal' | 'spoed';
klacht: string;
anamnese?: string;
diagnose?: string;
medicatie?: string;
bijlagen?: { naam: string; type: string; url: string }[];
}
Integratie met intake workflow
// Wanneer Zorgdomein actief is:
// 1. C38 (Scheduled Jobs) pollt Zorgdomein elke 15 minuten voor nieuwe verwijzingen
// 2. Nieuwe verwijzing → automatisch patient aanmaken (of matchen op BSN)
// 3. C35 (Workflow Engine) start intake_ggz workflow
// 4. C34 (Notificatie) meldt aan triagist: "Nieuwe verwijzing via Zorgdomein"
C43 — BI Module (voorbereiding)
Doel
Business Intelligence laag: materialized views, data-export en voorbereiding op dashboards voor management rapportages. Niet een volledige BI-tool, maar de datalaag die BI mogelijk maakt.
Waarom nodig
- Stuurinformatie: Productiecijfers per behandelaar, team, afdeling
- Wachttijden: Gemiddelde wachttijd aanmelding → intake (Treeknormen)
- Kwaliteitsindicatoren: ROM-scores, uitval, no-shows
- Financieel: Omzet per periode, ongedeclareerde prestaties
- Bezetting: Bed/verblijfsbezetting, personeel/patiënt ratio
Interface
// lib/bi/types.ts
interface BIQuery {
tenantId: string;
metric: BIMetric;
dimensions?: BIDimension[];
dateRange: { from: Date; to: Date };
filters?: Record<string, string | string[]>;
}
type BIMetric =
| 'productie_uren'
| 'aantal_consulten'
| 'wachttijd_intake'
| 'no_show_percentage'
| 'bezettingsgraad'
| 'omzet'
| 'ongedeclareerd'
| 'gemiddelde_behandelduur'
| 'uitval_percentage'
| 'incidenten_per_maand';
type BIDimension =
| 'behandelaar'
| 'team'
| 'afdeling'
| 'locatie'
| 'zorgtype'
| 'diagnose_groep'
| 'maand'
| 'week';
interface BIResult {
metric: BIMetric;
dimensions: Record<string, string>;
value: number;
previousValue?: number; // Vorige periode voor vergelijking
trend?: 'up' | 'down' | 'stable';
}
interface BIService {
query(query: BIQuery): Promise<BIResult[]>;
refreshViews(tenantId: string): Promise<void>;
exportCSV(query: BIQuery): Promise<string>; // CSV content
getAvailableMetrics(): BIMetric[];
}
Materialized views
-- Productie per behandelaar per maand
CREATE MATERIALIZED VIEW mv_productie_per_behandelaar AS
SELECT
z.tenant_id,
z.behandelaar_id,
u.display_name AS behandelaar_naam,
d.name AS afdeling,
DATE_TRUNC('month', z.datum) AS maand,
COUNT(*) AS aantal_prestaties,
SUM(z.duur_minuten) AS directe_minuten,
SUM(z.indirecte_tijd) AS indirecte_minuten,
SUM(z.duur_minuten + COALESCE(z.indirecte_tijd, 0)) AS totaal_minuten
FROM zorgprestaties z
JOIN users u ON u.id = z.behandelaar_id
LEFT JOIN departments d ON d.id = u.department_id
WHERE z.status != 'concept'
GROUP BY z.tenant_id, z.behandelaar_id, u.display_name, d.name, DATE_TRUNC('month', z.datum);
CREATE UNIQUE INDEX idx_mv_productie
ON mv_productie_per_behandelaar (tenant_id, behandelaar_id, maand);
-- Wachttijden (aanmelding → intake)
CREATE MATERIALIZED VIEW mv_wachttijden AS
SELECT
wi.tenant_id,
DATE_TRUNC('month', wi.created_at) AS maand,
wi.current_state,
COUNT(*) AS aantal,
AVG(EXTRACT(DAY FROM (
CASE WHEN wtl.to_state = 'triage'
THEN wtl.created_at ELSE NULL END
) - wi.created_at)) AS gem_dagen_tot_triage,
AVG(EXTRACT(DAY FROM (
CASE WHEN wtl.to_state = 'gestart'
THEN wtl.created_at ELSE NULL END
) - wi.created_at)) AS gem_dagen_tot_start
FROM workflow_instances wi
LEFT JOIN workflow_transitions_log wtl ON wtl.instance_id = wi.id
WHERE wi.entity_type = 'intake'
GROUP BY wi.tenant_id, DATE_TRUNC('month', wi.created_at), wi.current_state;
-- No-show percentage per maand
CREATE MATERIALIZED VIEW mv_no_shows AS
SELECT
a.tenant_id,
DATE_TRUNC('month', a.start_time) AS maand,
COUNT(*) AS totaal_afspraken,
COUNT(*) FILTER (WHERE a.status = 'no_show') AS no_shows,
ROUND(
COUNT(*) FILTER (WHERE a.status = 'no_show')::DECIMAL /
NULLIF(COUNT(*), 0) * 100, 1
) AS no_show_percentage
FROM appointments a
WHERE a.start_time < NOW()
GROUP BY a.tenant_id, DATE_TRUNC('month', a.start_time);
-- Refresh via scheduled job (C38)
-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_productie_per_behandelaar;
-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_wachttijden;
-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_no_shows;
Integratiepunten
| Bestaand component | Integratie |
|---|---|
| C17 Agenda Service | No-show data, bezettingsdata |
| C35 Workflow Engine | Wachttijden (intake doorlooptijden) |
| C37 Caching | BI resultaten cachen (namespace api:dashboard) |
| C38 Scheduled Jobs | REFRESH MATERIALIZED VIEW CONCURRENTLY elke 15-60 min |
| C40 ZPM Facturatie | Omzet en productiecijfers |
| C25 RBAC + ABAC | Alleen management/admin rollen mogen BI queries draaien |
Toekomstige uitbreiding
Als BI-behoeften groeien voorbij wat materialized views aankunnen:
- Metabase (self-hosted, open source) als BI frontend bovenop dezelfde PostgreSQL
- dbt voor data transformaties als de views complexer worden
- Export naar extern data warehouse indien nodig
Bijgewerkt Component Overzicht (volledig)
LAAG 1: INTERFACE
├── C01 Traditionele EPD UI
├── C02 Intent Command Center
├── C03 Spraak Input
└── C04 Block/Artifact systeem
LAAG 2: INTENT SYSTEEM
├── C05 Intent Registry
├── C06 Reflex Classifier
├── C07 Orchestrator
├── C08 Entity Extractor + Date Parser
├── C09 Action System
└── C10 Protocol Engine / Nudge
LAAG 3: KNOWLEDGE LAYER
├── C11 Protocol Rules Store
├── C12 RAG Pipeline
├── C13 Regelvalidatie UI
└── C14 Kennisbron Connectors
LAAG 4: DOMEIN / BUSINESS LOGIC
├── C15 Rapportage Service
├── C16 Overdracht Service
├── C17 Agenda Service
├── C18 Behandelplan Service
├── C19 Medicatie Service
├── C20 Intake Service
├── C21 Patiënt Service
└── C22 Toestemming Service
LAAG 5: ENTERPRISE FUNDAMENT
├── C23 Auth + SSO (Keycloak)
├── C24 Multi-tenancy (RLS)
├── C25 RBAC + ABAC Engine
├── C26 Audit Trail (NEN 7513)
├── C27 Field Encryption
├── C28 FHIR Export Service
├── C29 CDS Hooks Service
├── C30 Observability
├── C31 Circuit Breaker / Resilience
├── C32 Health Check Service
└── C33 PII Filter
LAAG 6: PLATFORM SERVICES ← NIEUW
├── C34 Notificatie Service
├── C35 Workflow Engine (State Machine)
├── C36 Full-Text Search Service
├── C37 Caching Layer
├── C38 Scheduled Jobs Service
└── C39 Webhook System
LAAG 7: EXTERNE INTEGRATIES ← NIEUW
├── C40 ZPM Facturatie Service
├── C41 Vecozo Connector (voorbereiding)
└── C42 Zorgdomein Connector (voorbereiding)
LAAG 8: ANALYTICS ← NIEUW
└── C43 BI Module (materialized views)
TOTAAL: 43 componenten (33 bestaand + 10 nieuw)
Bijgewerkt Bouwplan (fasen)
De 10 nieuwe componenten passen als volgt in het bestaande 5-fasen bouwplan:
FASE 1: FUNDAMENT (week 1-4) — uitbreiding
├── (bestaand) PostgreSQL, Keycloak, Drizzle, Docker, Caddy
├── (bestaand) Multi-tenancy, Audit Trail, RBAC
├── C38 Scheduled Jobs ← NIEUW (fundament voor alles)
└── C37 Caching Layer ← NIEUW (Redis al aanwezig voor BullMQ)
FASE 2: EPD CORE (week 5-10) — uitbreiding
├── (bestaand) Rapportage, Overdracht, Agenda, Behandelplan
├── (bestaand) Intent systeem, Reflex, Orchestrator
├── C34 Notificatie Service ← NIEUW
├── C35 Workflow Engine ← NIEUW
└── C40 ZPM Facturatie ← NIEUW (basisregistratie, geen Vecozo)
FASE 3: KNOWLEDGE LAYER (week 11-14) — uitbreiding
├── (bestaand) Protocol Rules, RAG Pipeline, Nudge
├── C36 Full-Text Search ← NIEUW
└── C43 BI Module ← NIEUW (materialized views aanmaken)
FASE 4: CONNECTIVITEIT (week 15-18) — uitbreiding
├── (bestaand) FHIR Connector, Connector admin UI
├── C39 Webhook System ← NIEUW
├── C41 Vecozo Connector ← NIEUW (interface + mock, geen live)
└── C42 Zorgdomein Connector ← NIEUW (interface + mock, geen live)
FASE 5: COMPLIANCE & HARDENING (week 19-22)
├── (bestaand) Encryption, Wvggz, Observability, Load test, Pentest
└── Vecozo/Zorgdomein live aansluiting (indien traject loopt)
Beslissingenlog (aanvullingen)
| # | Beslissing | Alternatieven | Rationale |
|---|---|---|---|
| D20 | Notificaties via SSE (geen WebSockets) | WebSockets, polling | Eenvoudiger, minder infra, notificaties zijn unidirectioneel |
| D21 | Eigen state machine (geen XState) | XState, Temporal.io | ~200 regels code, server-side, geen dependency bloat |
| D22 | PostgreSQL tsvector (geen Elasticsearch) | Elasticsearch, Meilisearch | Geen extra service, voldoende voor GGZ-schaal |
| D23 | Materialized views voor BI (geen apart warehouse) | Metabase direct, dbt, Snowflake | Start simpel, upgrade later als nodig |
| D24 | Webhook signing met HMAC-SHA256 | JWT, API keys | Industriestandaard (GitHub, Stripe), simpel te valideren |
| D25 | Geen PII in webhook payloads | Encrypted payloads | Defense in depth, ontvanger haalt data via API |
Appendix: Cross-referentie nieuwe componenten × use cases
| Use Case | C34 | C35 | C36 | C37 | C38 | C39 | C40 | C41 | C42 | C43 |
|---|---|---|---|---|---|---|---|---|---|---|
| UC-01 Dagnotitie | ● | ● | ● | |||||||
| UC-05 Overdracht | ● | ● | ● | |||||||
| UC-07 Zoeken | ● | ● | ||||||||
| UC-10 Afspraak | ● | ● | ● | |||||||
| UC-13 Intake status | ● | ● | ● | ● | ||||||
| UC-20 Nudge | ● | ● | ||||||||
| UC-21 Behandelplan | ● | ● | ● | ● | ● | |||||
| UC-22 Medicatie | ● | ● | ||||||||
| UC-23 Incident | ● | ● | ● | ● | ● | |||||
| UC-24 Wvggz | ● | ● | ● | |||||||
| UC-26 Audit log | ● | ● | ||||||||
| UC-30 Dashboard | ● | ● | ● | |||||||
| UC-32 Groepstherapie | ● | ● | ||||||||
| UC-33 Break-the-glass | ● |
● = component is betrokken bij deze use case
Appendix: Relatie tussen alle architectuurdocumenten (bijgewerkt)
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 platform-connectiviteit-selfhosted.md)
│ Status: Functioneel nog geldig, stack is gewijzigd
│
├── usecases-technische-componenten.md
│ Beschrijft: 33 use cases × 34 componenten validatie
│ Scope: Gap analyse, bouwvolgorde, kruistabel
│ Status: Uitgebreid met 10 nieuwe componenten (zie dit document)
│
├── platform-connectiviteit-selfhosted.md
│ Beschrijft: Self-hosted stack, platform splitsing, connectors
│ Vervangt: Stack keuzes uit enterprise-epd-architectuur.md
│ Voegt toe: Connector API, deployment modellen, Keycloak, Drizzle
│
└── enterprise-componenten-uitbreiding.md ← DIT DOCUMENT
Beschrijft: 10 nieuwe componenten (C34–C43) als voorbereiding
Scope: Interfaces, database schemas, integratiepunten, fasering
Status: Voorbereiding — nog niet gebouwd