# 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 ```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; expiresAt?: Date; // Automatisch opruimen } interface NotificationPreferences { userId: string; tenantId: string; channelDefaults: Record; 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; markAllAsRead(userId: string, category?: NotificationCategory): Promise; getUnread(userId: string, limit?: number): Promise; getUnreadCount(userId: string): Promise>; updatePreferences(prefs: NotificationPreferences): Promise; registerEscalationRule(rule: EscalationRule): Promise; } ``` ### 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; } 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; } // Service interface interface WorkflowService { // Definitie beheer registerDefinition(def: WorkflowDefinition): Promise; getDefinition(name: string, version?: number): Promise; // Instance beheer startWorkflow(params: { definitionName: string; entityType: string; entityId: string; tenantId: string; initiatorUserId: string; }): Promise; transition(params: { instanceId: string; trigger: string; userId: string; comment?: string; }): Promise; getCurrentState(instanceId: string): Promise; getAvailableTransitions(instanceId: string, userId: string): Promise; getInstancesByEntity(entityType: string, entityId: string): Promise; // Deadline monitoring getOverdueInstances(tenantId: string): Promise; } ``` ### 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; indexDocument(params: { entityType: string; entityId: string; tenantId: string; content: string; metadata: Record; }): Promise; removeDocument(entityType: string, entityId: string): Promise; 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(key: string, namespace: CacheNamespace): Promise; set(key: string, value: T, options: CacheOptions): Promise; delete(key: string, namespace: CacheNamespace): Promise; invalidateByTag(tag: string): Promise; // Returns count invalidateNamespace(namespace: CacheNamespace): Promise; // Convenience: get-or-set pattern getOrSet( key: string, factory: () => Promise, options: CacheOptions ): Promise; // 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 = { '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; 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; // Returns job ID getStatus(jobId: string): Promise; getFailedJobs(limit?: number): Promise; retryFailed(jobId: string): Promise; getMetrics(): Promise; } 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; // 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; // 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): Promise; update(id: string, updates: Partial): Promise; delete(id: string): Promise; list(tenantId: string): Promise; // Event dispatching (intern, aangeroepen door andere services) dispatch(event: WebhookEvent, tenantId: string, data: Record): Promise; // Delivery monitoring getDeliveryLog(webhookId: string, limit?: number): Promise; retryDelivery(deliveryLogId: string): Promise; } ``` ### 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: // X-Webhook-Event: report.created // X-Webhook-Signature: sha256= // X-Webhook-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; } 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): Promise; koppelAanAfspraak(prestatieId: string, appointmentId: string): Promise; koppelAanRapportage(prestatieId: string, reportId: string): Promise; // DBC beheer openTraject(traject: Omit): Promise; sluitTraject(trajectId: string): Promise; // Facturatie genereerBatch(tenantId: string, periode: string): Promise; controleerBatch(batchId: string): Promise; // Controle regels markeerVerstuurd(batchId: string, vecozoRef: string): Promise; // Overzichten getOpenPrestaties(tenantId: string, filters?: object): Promise; getTrajecten(patientId: string): Promise; getOmzetOverzicht(tenantId: string, periode: string): Promise; } 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; perZorgtype: Record; } ``` ### 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; // Declaratie versturen submitDeclaratie(params: { batchId: string; declaratieXml: string; // Vektis EI-standaard }): Promise; // Declaratie status opvragen getDeclaratieStatus(vecozoRef: string): Promise; // Machtiging aanvragen (voor bepaalde zorgtypen) requestMachtiging(params: { patientBsn: string; zorgtype: string; motivatie: string; }): Promise; // Health check isAvailable(): Promise; } 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 { console.warn('[Vecozo] Mock connector — niet verbonden met Vecozo'); return { verzekerd: true, verzekeraar: 'Mock Verzekeraar', uzovi: '0000', }; } async submitDeclaratie(): Promise { 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; // Verwijzing accepteren/afwijzen acceptReferral(verwijzingId: string): Promise; rejectReferral(verwijzingId: string, reden: string): Promise; // Status terugkoppelen updateReferralStatus(verwijzingId: string, status: string): Promise; // Health check isAvailable(): Promise; } 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; } 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; value: number; previousValue?: number; // Vorige periode voor vergelijking trend?: 'up' | 'down' | 'stable'; } interface BIService { query(query: BIQuery): Promise; refreshViews(tenantId: string): Promise; exportCSV(query: BIQuery): Promise; // 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 (C34–C43) als voorbereiding Scope: Interfaces, database schemas, integratiepunten, fasering Status: Voorbereiding — nog niet gebouwd ```