Files
triqura-ecd/docs/architecture/enterprise-componenten-uitbreiding.md
2026-07-09 23:16:28 +02:00

1735 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (C34C43)
> **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 (C01C33)
- **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
```typescript
// 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
```sql
-- 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
```typescript
// 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
```sql
-- 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
```typescript
// 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: `zoeken` intent moet rapportages kunnen doorzoeken
### Interface
```typescript
// 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
```sql
-- 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
```typescript
// 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
```typescript
// 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 `maxDurationDays` overschrijden
- **Notificatie digests**: Dagelijkse email samenvattingen
- **ZPM facturatie**: Maandelijkse facturatie batch
- **Protocol sync**: Periodiek nieuwe protocollen ophalen/indexeren
- **Caching**: Periodiek dashboard aggregaties vernieuwen
### Interface
```typescript
// 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
```typescript
// 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
```typescript
// 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
```sql
-- 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
```typescript
// 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:
```typescript
// 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
```typescript
// 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
```sql
-- 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
```typescript
// 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
```typescript
// 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)
1. **UZI-certificaat**: Nodig voor mTLS communicatie met Vecozo
2. **AGB-code**: Instelling moet geregistreerd zijn
3. **Testomgeving**: Vecozo biedt een acceptatieomgeving
4. **Vektis standaarden**: Declaratieberichten in EI-standaard XML formaat
5. **BSN-verificatie**: Koppeling met BRP via Vecozo
### Placeholder implementatie
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```sql
-- 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 (C34C43) als voorbereiding
Scope: Interfaces, database schemas, integratiepunten, fasering
Status: Voorbereiding — nog niet gebouwd
```