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

59 KiB
Raw Blame History

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

// lib/notifications/types.ts

type NotificationChannel = 'in_app' | 'email' | 'both';
type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
type NotificationCategory =
  | 'overdracht'
  | 'shift'
  | 'incident'
  | 'wvggz_termijn'
  | 'medicatie'
  | 'behandelplan'
  | 'protocol_nudge'
  | 'systeem'
  | 'taak';

interface NotificationPayload {
  recipientUserId: string;
  tenantId: string;
  category: NotificationCategory;
  priority: NotificationPriority;
  channel: NotificationChannel;
  title: string;                           // NL, max 120 chars
  body: string;                            // NL, max 500 chars
  actionUrl?: string;                      // Deep link naar EPD pagina
  relatedPatientId?: string;               // Voor audit trail koppeling
  relatedEntityType?: string;              // 'report' | 'appointment' | 'treatment_plan'
  relatedEntityId?: string;
  metadata?: Record<string, unknown>;
  expiresAt?: Date;                        // Automatisch opruimen
}

interface NotificationPreferences {
  userId: string;
  tenantId: string;
  channelDefaults: Record<NotificationCategory, NotificationChannel>;
  quietHours?: { start: string; end: string };  // "22:00" - "07:00"
  emailDigest: 'immediate' | 'hourly' | 'daily' | 'off';
}

interface EscalationRule {
  id: string;
  tenantId: string;
  category: NotificationCategory;
  triggerCondition: string;                // bv. "unread_after_minutes > 60"
  escalateToRole: string;                  // bv. "teamleider"
  escalateChannel: NotificationChannel;
  maxEscalations: number;
}

// Service interface
interface NotificationService {
  send(payload: NotificationPayload): Promise<{ notificationId: string }>;
  sendBulk(payloads: NotificationPayload[]): Promise<{ sent: number; failed: number }>;
  markAsRead(notificationId: string, userId: string): Promise<void>;
  markAllAsRead(userId: string, category?: NotificationCategory): Promise<void>;
  getUnread(userId: string, limit?: number): Promise<Notification[]>;
  getUnreadCount(userId: string): Promise<Record<NotificationCategory, number>>;
  updatePreferences(prefs: NotificationPreferences): Promise<void>;
  registerEscalationRule(rule: EscalationRule): Promise<void>;
}

Database schema

-- Notificaties tabel
CREATE TABLE notifications (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  recipient_id    UUID NOT NULL REFERENCES users(id),
  category        TEXT NOT NULL,
  priority        TEXT NOT NULL DEFAULT 'normal',
  channel         TEXT NOT NULL DEFAULT 'in_app',
  title           TEXT NOT NULL,
  body            TEXT NOT NULL,
  action_url      TEXT,
  related_patient_id UUID REFERENCES patients(id),
  related_entity_type TEXT,
  related_entity_id UUID,
  metadata        JSONB DEFAULT '{}',
  read_at         TIMESTAMPTZ,
  sent_at         TIMESTAMPTZ DEFAULT NOW(),
  expires_at      TIMESTAMPTZ,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes voor snelle queries
CREATE INDEX idx_notifications_recipient_unread
  ON notifications (recipient_id, read_at)
  WHERE read_at IS NULL;

CREATE INDEX idx_notifications_tenant_category
  ON notifications (tenant_id, category, created_at DESC);

-- RLS: gebruiker ziet alleen eigen notificaties
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;

CREATE POLICY notifications_own ON notifications
  FOR ALL USING (
    tenant_id = current_setting('app.current_tenant_id')::UUID
    AND recipient_id = current_setting('app.current_user_id')::UUID
  );

-- Voorkeuren per gebruiker
CREATE TABLE notification_preferences (
  user_id         UUID NOT NULL REFERENCES users(id),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  channel_defaults JSONB NOT NULL DEFAULT '{}',
  quiet_hours_start TIME,
  quiet_hours_end   TIME,
  email_digest    TEXT NOT NULL DEFAULT 'immediate',
  updated_at      TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (user_id, tenant_id)
);

-- Escalatie regels per tenant
CREATE TABLE escalation_rules (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  category        TEXT NOT NULL,
  trigger_condition TEXT NOT NULL,
  escalate_to_role TEXT NOT NULL,
  escalate_channel TEXT NOT NULL DEFAULT 'both',
  max_escalations INT NOT NULL DEFAULT 3,
  active          BOOLEAN DEFAULT TRUE,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

Integratiepunten

Bestaand component Integratie
C10 Protocol Engine Nudges die niet in-app getoond worden → notificatie
C15 Rapportage Service Nieuwe rapportage voor overdracht → notificatie aan team
C16 Overdracht Service AI-samenvatting gereed → notificatie
C17 Agenda Service Afspraakherinnering → notificatie
C22 Toestemming Service Wvggz termijn nadert → escalatie notificatie
C25 RBAC + ABAC Escalatie naar rol, niet naar specifieke persoon
C38 Scheduled Jobs Periodieke digest emails, escalatie checks

Technologiekeuze

  • In-app: Server-Sent Events (SSE) vanuit Next.js API route — simpeler dan WebSockets, voldoende voor notificaties
  • Email: Postal (self-hosted, D19) via BullMQ job queue
  • Escalatie: BullMQ delayed jobs — check na X minuten of notificatie gelezen is

Trade-offs

Keuze Alternatief Rationale
SSE voor real-time WebSockets Eenvoudiger, minder infra, unidirectioneel is voldoende
Escalatie via BullMQ Aparte escalatie service Hergebruik bestaande infra (C38), KISS
Notificatie in PostgreSQL Aparte message store (Redis) Eén bron van waarheid, auditbaar, RLS

C35 — Workflow Engine (State Machine)

Doel

Generieke state machine die klinische workflows bestuurt: intake-trajecten, behandelplannen, Wvggz-procedures, MIC-meldingen. Dwingt af dat stappen in de juiste volgorde gebeuren, door de juiste rollen, binnen wettelijke termijnen.

Waarom nodig

  • Intake: Aanmelding → Triage → Diagnostiek → Behandelplan → Start behandeling
  • Wvggz: Verzoekschrift → Beoordeling → Machtiging → Uitvoering → Evaluatie (wettelijke termijnen!)
  • MIC-melding: Registratie → Analyse → Maatregelen → Evaluatie → Afsluiting
  • Behandelplan: Concept → Review → Akkoord patiënt → Actief → Evaluatie
  • Ontslag: Voorbereiding → Nazorgplan → Overdracht → Ontslag

Interface

// lib/workflow/types.ts

interface WorkflowDefinition {
  id: string;
  name: string;                            // bv. "intake_ggz"
  version: number;
  tenantId: string | null;                 // null = systeembreed
  states: WorkflowState[];
  transitions: WorkflowTransition[];
  initialState: string;
  terminalStates: string[];
}

interface WorkflowState {
  name: string;                            // bv. "triage", "diagnostiek"
  label: string;                           // NL label: "Triage"
  requiredRole?: string;                   // Welke rol mag in deze state werken
  maxDurationDays?: number;                // Wettelijke of interne deadline
  onEnter?: WorkflowAction[];              // Acties bij betreden state
  onExit?: WorkflowAction[];               // Acties bij verlaten state
  requiredFields?: string[];               // Welke velden ingevuld moeten zijn
}

interface WorkflowTransition {
  from: string;
  to: string;
  trigger: string;                         // bv. "approve", "reject", "timeout"
  requiredRole?: string;                   // Wie mag deze transitie uitvoeren
  guard?: string;                          // Conditie (bv. "all_required_fields_filled")
  actions?: WorkflowAction[];              // Acties bij transitie
}

interface WorkflowAction {
  type: 'notify' | 'create_task' | 'update_field' | 'escalate' | 'audit_log';
  config: Record<string, unknown>;
}

interface WorkflowInstance {
  id: string;
  workflowDefinitionId: string;
  tenantId: string;
  entityType: string;                      // 'intake' | 'behandelplan' | 'wvggz' | 'mic_melding'
  entityId: string;                        // ID van het gerelateerde object
  currentState: string;
  stateHistory: StateTransitionRecord[];
  createdAt: Date;
  updatedAt: Date;
  completedAt: Date | null;
}

interface StateTransitionRecord {
  fromState: string;
  toState: string;
  trigger: string;
  userId: string;
  timestamp: Date;
  comment?: string;
  metadata?: Record<string, unknown>;
}

// Service interface
interface WorkflowService {
  // Definitie beheer
  registerDefinition(def: WorkflowDefinition): Promise<void>;
  getDefinition(name: string, version?: number): Promise<WorkflowDefinition>;

  // Instance beheer
  startWorkflow(params: {
    definitionName: string;
    entityType: string;
    entityId: string;
    tenantId: string;
    initiatorUserId: string;
  }): Promise<WorkflowInstance>;

  transition(params: {
    instanceId: string;
    trigger: string;
    userId: string;
    comment?: string;
  }): Promise<WorkflowInstance>;

  getCurrentState(instanceId: string): Promise<WorkflowInstance>;
  getAvailableTransitions(instanceId: string, userId: string): Promise<WorkflowTransition[]>;
  getInstancesByEntity(entityType: string, entityId: string): Promise<WorkflowInstance[]>;

  // Deadline monitoring
  getOverdueInstances(tenantId: string): Promise<WorkflowInstance[]>;
}

Database schema

-- Workflow definities (versioned)
CREATE TABLE workflow_definitions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name            TEXT NOT NULL,
  version         INT NOT NULL DEFAULT 1,
  tenant_id       UUID REFERENCES tenants(id),  -- NULL = systeembreed
  definition      JSONB NOT NULL,                -- Volledige WorkflowDefinition
  active          BOOLEAN DEFAULT TRUE,
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(name, version, tenant_id)
);

-- Workflow instances
CREATE TABLE workflow_instances (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workflow_def_id     UUID NOT NULL REFERENCES workflow_definitions(id),
  tenant_id           UUID NOT NULL REFERENCES tenants(id),
  entity_type         TEXT NOT NULL,
  entity_id           UUID NOT NULL,
  current_state       TEXT NOT NULL,
  completed_at        TIMESTAMPTZ,
  created_at          TIMESTAMPTZ DEFAULT NOW(),
  updated_at          TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_workflow_instances_entity
  ON workflow_instances (entity_type, entity_id);

CREATE INDEX idx_workflow_instances_state
  ON workflow_instances (tenant_id, current_state)
  WHERE completed_at IS NULL;

-- State transitie historie (immutable)
CREATE TABLE workflow_transitions_log (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  instance_id     UUID NOT NULL REFERENCES workflow_instances(id),
  from_state      TEXT NOT NULL,
  to_state        TEXT NOT NULL,
  trigger         TEXT NOT NULL,
  user_id         UUID NOT NULL REFERENCES users(id),
  comment         TEXT,
  metadata        JSONB DEFAULT '{}',
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Immutable: geen UPDATE of DELETE
CREATE RULE workflow_transitions_no_update AS
  ON UPDATE TO workflow_transitions_log DO INSTEAD NOTHING;
CREATE RULE workflow_transitions_no_delete AS
  ON DELETE TO workflow_transitions_log DO INSTEAD NOTHING;

-- RLS
ALTER TABLE workflow_instances ENABLE ROW LEVEL SECURITY;
ALTER TABLE workflow_transitions_log ENABLE ROW LEVEL SECURITY;

CREATE POLICY workflow_instances_tenant ON workflow_instances
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY workflow_transitions_tenant ON workflow_transitions_log
  FOR ALL USING (
    instance_id IN (
      SELECT id FROM workflow_instances
      WHERE tenant_id = current_setting('app.current_tenant_id')::UUID
    )
  );

Voorgedefinieerde workflows

// lib/workflow/definitions/intake-ggz.ts
const intakeGGZ: WorkflowDefinition = {
  id: 'wf-intake-ggz',
  name: 'intake_ggz',
  version: 1,
  tenantId: null,
  initialState: 'aangemeld',
  terminalStates: ['gestart', 'afgewezen'],
  states: [
    { name: 'aangemeld', label: 'Aangemeld', maxDurationDays: 5,
      onEnter: [{ type: 'notify', config: { category: 'taak', role: 'triagist' } }] },
    { name: 'triage', label: 'Triage', requiredRole: 'triagist', maxDurationDays: 3 },
    { name: 'diagnostiek', label: 'Diagnostiek', requiredRole: 'behandelaar', maxDurationDays: 14 },
    { name: 'behandelplan', label: 'Behandelplan opstellen', requiredRole: 'behandelaar', maxDurationDays: 7,
      requiredFields: ['diagnose', 'behandeldoelen'] },
    { name: 'akkoord_patient', label: 'Akkoord patiënt', maxDurationDays: 5 },
    { name: 'gestart', label: 'Behandeling gestart' },
    { name: 'afgewezen', label: 'Afgewezen (doorverwijzing)' },
  ],
  transitions: [
    { from: 'aangemeld', to: 'triage', trigger: 'start_triage', requiredRole: 'triagist' },
    { from: 'triage', to: 'diagnostiek', trigger: 'accept', requiredRole: 'triagist' },
    { from: 'triage', to: 'afgewezen', trigger: 'reject', requiredRole: 'triagist',
      actions: [{ type: 'notify', config: { category: 'taak', message: 'Doorverwijsbrief opstellen' } }] },
    { from: 'diagnostiek', to: 'behandelplan', trigger: 'diagnose_complete', requiredRole: 'behandelaar' },
    { from: 'behandelplan', to: 'akkoord_patient', trigger: 'submit_plan', guard: 'all_required_fields_filled' },
    { from: 'akkoord_patient', to: 'gestart', trigger: 'patient_agrees' },
    { from: 'akkoord_patient', to: 'behandelplan', trigger: 'patient_disagrees' },
  ],
};

Integratiepunten

Bestaand component Integratie
C10 Protocol Engine Nudge kan workflow transitie suggereren
C15 Rapportage Service MIC-melding start mic_workflow instance
C18 Behandelplan Service Behandelplan lifecycle via workflow
C20 Intake Service Intake trajectory via workflow
C22 Toestemming Service Wvggz-procedure als workflow met wettelijke deadlines
C25 RBAC + ABAC requiredRole in transitions → RBAC check
C26 Audit Trail Elke transitie → audit log entry
C34 Notificatie onEnter, onExit, transitie acties → notificaties
C38 Scheduled Jobs Deadline monitoring: check dagelijks op maxDurationDays overschrijding

Technologiekeuze

  • Geen externe library: State machine logica is ~200 regels TypeScript. XState is overkill voor server-side workflow.
  • Definities in code + database: Systeem-workflows in code (version controlled), tenant-specifieke aanpassingen in database.
  • Transitie log immutable: Net als audit trail — compliance vereist onveranderbare historie.

C36 — Full-Text Search Service

Doel

Zoeken door klinische notities, rapportages en patiëntgegevens op basis van vrije tekst. Ondersteunt het intent "zoeken" (C06/C07) en de traditionele EPD zoekbalk.

Waarom nodig

  • Zorgverlener zoekt: "alle rapportages over agressie bij Jan Janssen afgelopen maand"
  • Compliance officer zoekt: "alle incidenten met fixatie in 2025"
  • Intent systeem: zoeken intent moet rapportages kunnen doorzoeken

Interface

// lib/search/types.ts

interface SearchQuery {
  tenantId: string;
  query: string;                           // Vrije tekst, NL
  filters?: {
    entityTypes?: ('report' | 'patient' | 'appointment' | 'treatment_plan')[];
    patientId?: string;
    dateRange?: { from: Date; to: Date };
    reportCategories?: string[];
    authorId?: string;
    departmentId?: string;
  };
  pagination?: { offset: number; limit: number };
  highlight?: boolean;                     // Markeer matches in resultaat
}

interface SearchResult {
  entityType: string;
  entityId: string;
  score: number;
  title: string;
  snippet: string;                         // Fragment met context
  highlights?: string[];                   // Gemarkeerde matches
  metadata: {
    patientName?: string;
    authorName?: string;
    createdAt: Date;
    category?: string;
  };
}

interface SearchResponse {
  results: SearchResult[];
  total: number;
  took: number;                            // milliseconds
  query: string;
}

// Service interface
interface SearchService {
  search(query: SearchQuery): Promise<SearchResponse>;
  indexDocument(params: {
    entityType: string;
    entityId: string;
    tenantId: string;
    content: string;
    metadata: Record<string, unknown>;
  }): Promise<void>;
  removeDocument(entityType: string, entityId: string): Promise<void>;
  reindexAll(tenantId: string): Promise<{ indexed: number }>;
}

Database schema

-- Full-text search configuratie voor Nederlands
CREATE TEXT SEARCH CONFIGURATION dutch_medical (COPY = dutch);

-- Medische synoniemen dictionary (uitbreidbaar)
-- In productie: custom dictionary file met medische termen
-- ALTER TEXT SEARCH CONFIGURATION dutch_medical
--   ALTER MAPPING FOR asciiword WITH dutch_medical_syn, dutch_stem;

-- Search index op reports
ALTER TABLE reports ADD COLUMN IF NOT EXISTS
  search_vector TSVECTOR
  GENERATED ALWAYS AS (
    setweight(to_tsvector('dutch', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('dutch', coalesce(content, '')), 'B') ||
    setweight(to_tsvector('dutch', coalesce(
      structured_data->>'category', ''
    )), 'C')
  ) STORED;

CREATE INDEX idx_reports_search ON reports USING GIN (search_vector);

-- Search index op patients
ALTER TABLE patients ADD COLUMN IF NOT EXISTS
  search_vector TSVECTOR
  GENERATED ALWAYS AS (
    setweight(to_tsvector('dutch', coalesce(first_name, '')), 'A') ||
    setweight(to_tsvector('dutch', coalesce(last_name, '')), 'A') ||
    setweight(to_tsvector('dutch', coalesce(bsn, '')), 'A')
  ) STORED;

CREATE INDEX idx_patients_search ON patients USING GIN (search_vector);

-- Zoekquery met ranking
-- Voorbeeld: zoek "agressie medicatie" in rapportages van een patiënt
-- SELECT id, title,
--   ts_rank_cd(search_vector, query) AS rank,
--   ts_headline('dutch', content, query, 'MaxWords=35, MinWords=15') AS snippet
-- FROM reports,
--   plainto_tsquery('dutch', 'agressie medicatie') query
-- WHERE search_vector @@ query
--   AND patient_id = $1
--   AND tenant_id = current_setting('app.current_tenant_id')::UUID
--   AND deleted_at IS NULL
-- ORDER BY rank DESC
-- LIMIT 20;

Integratiepunten

Bestaand component Integratie
C06 Reflex / C07 Orchestrator zoeken intent → SearchService.search()
C09 Action System Search resultaten als block in canvas
C15 Rapportage Service Bij create/update → automatisch geïndexeerd (GENERATED column)
C21 Patiënt Service Patiënt zoeken → search_vector op patients
C24 Multi-tenancy RLS op search queries: tenant_id filter

Technologiekeuze

  • PostgreSQL tsvector (geen Elasticsearch): Eenvoudiger stack, geen extra service, voldoende voor GGZ-schaal (~100k-1M documenten)
  • GENERATED ALWAYS AS STORED: Automatische index-update bij write, geen aparte indexer nodig
  • Nederlandse stemmer: Ingebouwd in PostgreSQL, uitbreidbaar met medische synoniemen dictionary

Trade-offs

Keuze Alternatief Rationale
PostgreSQL tsvector Elasticsearch, Meilisearch, Typesense Geen extra service, voldoende voor verwacht volume
GENERATED column Trigger-based indexing Eenvoudiger, altijd in sync, geen race conditions
ts_rank_cd Custom scoring Standaard PostgreSQL, goed genoeg voor eerste iteratie

Schaalgrens

PostgreSQL full-text search werkt goed tot ~5M documenten. Als het systeem voorbij die grens groeit, kan Meilisearch (self-hosted, Rust, <100MB RAM) als drop-in replacement dienen zonder interface-wijzigingen.


C37 — Caching Layer

Doel

Caching van dure operaties: AI-responses, API-responses, sessiedata, en veelgevraagde queries. Vermindert latency, kosten (minder AI API calls), en database load.

Waarom nodig

  • AI kosten: Dezelfde overdracht-samenvatting wordt meerdere keren bekeken → cache
  • Performance: Patient zoekresultaten, dashboard aggregaties, agenda views
  • Protocol Engine: RAG results cachen zodat dezelfde protocol-query niet steeds opnieuw draait
  • Sessiedata: Active patient context, recente navigatie

Interface

// lib/cache/types.ts

type CacheNamespace =
  | 'ai:summary'          // Overdracht AI samenvattingen
  | 'ai:classification'   // Intent classificatie resultaten
  | 'ai:rag'              // RAG pipeline resultaten
  | 'api:patients'        // Patient zoekresultaten
  | 'api:agenda'          // Agenda views
  | 'api:dashboard'       // Dashboard aggregaties
  | 'session:context'     // Active patient, recente acties
  | 'config:protocols';   // Protocol definities

interface CacheOptions {
  ttl: number;                             // Seconds
  namespace: CacheNamespace;
  tags?: string[];                         // Voor gerichte invalidatie
}

interface CacheService {
  get<T>(key: string, namespace: CacheNamespace): Promise<T | null>;
  set<T>(key: string, value: T, options: CacheOptions): Promise<void>;
  delete(key: string, namespace: CacheNamespace): Promise<void>;
  invalidateByTag(tag: string): Promise<number>;    // Returns count
  invalidateNamespace(namespace: CacheNamespace): Promise<void>;

  // Convenience: get-or-set pattern
  getOrSet<T>(
    key: string,
    factory: () => Promise<T>,
    options: CacheOptions
  ): Promise<T>;

  // Stats
  getStats(): Promise<{
    hitRate: number;
    memoryUsage: number;
    keyCount: number;
  }>;
}

TTL strategie

Namespace TTL Invalidatie
ai:summary 30 min Bij nieuwe rapportage voor die patiënt
ai:classification 5 min Zelden — zelfde input = zelfde output
ai:rag 60 min Bij protocol update
api:patients 10 min Bij patient update
api:agenda 5 min Bij appointment create/update/delete
api:dashboard 15 min Periodiek (C38 scheduled job)
session:context 60 min Bij navigatie-actie
config:protocols 24 uur Bij protocol wijziging

Implementatie

// lib/cache/redis-cache.ts

import Redis from 'ioredis';

const NAMESPACE_PREFIX: Record<CacheNamespace, string> = {
  'ai:summary':          'ai:sum:',
  'ai:classification':   'ai:cls:',
  'ai:rag':              'ai:rag:',
  'api:patients':        'api:pat:',
  'api:agenda':          'api:agd:',
  'api:dashboard':       'api:dsh:',
  'session:context':     'ses:ctx:',
  'config:protocols':    'cfg:prt:',
};

// Cache key inclusief tenant voor isolatie
function buildKey(namespace: CacheNamespace, tenantId: string, key: string): string {
  return `${NAMESPACE_PREFIX[namespace]}${tenantId}:${key}`;
}

// Tag-based invalidatie via Redis Sets
// Set "tag:{tagName}" bevat alle keys met die tag
// Bij invalidateByTag: SMEMBERS + DEL pipeline

Integratiepunten

Bestaand component Integratie
C07 Orchestrator Cache classificatie resultaten (zelfde input → zelfde output)
C12 RAG Pipeline Cache vector search resultaten per query hash
C16 Overdracht Service Cache AI-samenvattingen per patiënt + shift
C24 Multi-tenancy Cache keys bevatten tenant_id — volledige isolatie
C31 Circuit Breaker Bij AI service down → serve from cache (stale-while-revalidate)

Technologiekeuze

  • Redis (al in stack voor BullMQ, D18): Hergebruik bestaande Redis instance
  • ioredis: Standaard Node.js Redis client, pipeline support, cluster-ready

Geen gevoelige data in cache

BSN, diagnoses en andere PII worden nooit direct gecached. Alleen:

  • Hashed keys met verwijzing naar encrypted database records
  • AI-gegenereerde samenvattingen (geen ruwe patiëntdata)
  • Aggregaties en counts

C38 — Scheduled Jobs Service

Doel

Betrouwbare uitvoering van periodieke en geplande taken: audit trail cleanup, protocol synchronisatie, facturatie batches, deadline monitoring, email digests.

Waarom nodig

  • Audit trail: Partities aanmaken, oude partities archiveren
  • Workflow deadlines: Dagelijks checken of workflows hun 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

// lib/jobs/types.ts

interface JobDefinition {
  name: string;                            // Unieke naam: "audit.partition.create"
  handler: (payload: unknown) => Promise<void>;
  schedule?: string;                       // Cron syntax: "0 2 * * *" (02:00 dagelijks)
  retries?: number;                        // Default: 3
  backoff?: { type: 'exponential' | 'fixed'; delay: number };
  timeout?: number;                        // Milliseconds
  concurrency?: number;                    // Max parallel workers
}

interface ScheduledJobsService {
  register(definition: JobDefinition): void;
  enqueue(jobName: string, payload?: unknown, options?: {
    delay?: number;                        // Milliseconds
    priority?: number;                     // 1 (highest) - 10 (lowest)
    tenantId?: string;
  }): Promise<string>;                     // Returns job ID

  getStatus(jobId: string): Promise<JobStatus>;
  getFailedJobs(limit?: number): Promise<FailedJob[]>;
  retryFailed(jobId: string): Promise<void>;
  getMetrics(): Promise<JobMetrics>;
}

interface JobMetrics {
  active: number;
  waiting: number;
  completed: number;
  failed: number;
  delayed: number;
}

Geplande jobs

// lib/jobs/definitions.ts

const scheduledJobs: JobDefinition[] = [
  {
    name: 'audit.partition.create',
    schedule: '0 0 25 * *',               // 25e van de maand, partitie voor volgende maand
    handler: createNextAuditPartition,
    retries: 3,
  },
  {
    name: 'workflow.deadline.check',
    schedule: '0 8 * * *',                // Dagelijks 08:00
    handler: checkWorkflowDeadlines,       // Overschrijdingen → escalatie notificatie
    retries: 2,
  },
  {
    name: 'notifications.digest',
    schedule: '0 7 * * *',                // Dagelijks 07:00
    handler: sendDailyDigests,             // Email digest aan users met die voorkeur
    retries: 2,
  },
  {
    name: 'zpm.facturatie.batch',
    schedule: '0 2 1 * *',                // 1e van de maand, 02:00
    handler: runZPMFacturatieBatch,        // Maandelijkse facturatie
    retries: 3,
    timeout: 600_000,                      // 10 minuten
  },
  {
    name: 'protocol.sync',
    schedule: '0 3 * * 0',                // Zondag 03:00
    handler: syncProtocolDocuments,        // RAG pipeline re-index
    retries: 2,
    timeout: 300_000,
  },
  {
    name: 'cache.dashboard.refresh',
    schedule: '*/15 * * * *',             // Elke 15 minuten
    handler: refreshDashboardCache,
    retries: 1,
  },
  {
    name: 'notifications.escalation.check',
    schedule: '*/5 * * * *',              // Elke 5 minuten
    handler: checkEscalations,             // Ongelezen urgente notificaties → escaleer
    retries: 1,
  },
];

Integratiepunten

Bestaand component Integratie
C26 Audit Trail Partitie management, archivering
C34 Notificatie Digest emails, escalatie checks
C35 Workflow Engine Deadline monitoring
C37 Caching Dashboard cache verversing
C40 ZPM Facturatie Maandelijkse batch
C12 RAG Pipeline Protocol re-indexering

Technologiekeuze

  • BullMQ (al gekozen, D18): Betrouwbare job queue met Redis backing, cron support, retry logic, dashboard (Bull Board)
  • Bull Board: Web UI voor job monitoring — beschikbaar voor admin users

C39 — Webhook System

Doel

Outbound webhooks voor externe integraties: externe systemen kunnen zich abonneren op events in het EPD platform. Essentieel voor de connector-architectuur (D17) en toekomstige integraties.

Waarom nodig

  • Connector API: Extern EPD wil weten wanneer er een nieuw rapport is aangemaakt
  • Externe systemen: Facturatiesysteem, CRM, onderzoeksplatform willen events ontvangen
  • Toekomstige integraties: Vecozo, Zorgdomein, BI-tools

Interface

// lib/webhooks/types.ts

type WebhookEvent =
  | 'report.created'
  | 'report.updated'
  | 'report.deleted'
  | 'patient.admitted'
  | 'patient.discharged'
  | 'appointment.created'
  | 'appointment.updated'
  | 'appointment.cancelled'
  | 'treatment_plan.activated'
  | 'treatment_plan.updated'
  | 'workflow.state_changed'
  | 'incident.reported'
  | 'wvggz.status_changed';

interface WebhookRegistration {
  id: string;
  tenantId: string;
  url: string;                             // HTTPS endpoint
  events: WebhookEvent[];                  // Welke events
  secret: string;                          // HMAC-SHA256 signing secret
  active: boolean;
  description?: string;
  createdBy: string;
  headers?: Record<string, string>;        // Extra headers (auth tokens etc.)
  retryPolicy?: {
    maxRetries: number;                    // Default: 5
    backoffMs: number;                     // Default: 1000, exponential
  };
}

interface WebhookPayload {
  id: string;                              // Unique event ID
  event: WebhookEvent;
  tenantId: string;
  timestamp: string;                       // ISO 8601
  data: Record<string, unknown>;           // Event-specifieke data
  // NOOIT PII in webhook payload — alleen IDs en metadata
}

interface WebhookDeliveryLog {
  id: string;
  webhookId: string;
  event: WebhookEvent;
  payload: WebhookPayload;
  responseStatus: number | null;
  responseBody?: string;
  attempt: number;
  deliveredAt: Date | null;
  nextRetryAt: Date | null;
  error?: string;
}

// Service interface
interface WebhookService {
  register(registration: Omit<WebhookRegistration, 'id' | 'secret'>): Promise<WebhookRegistration>;
  update(id: string, updates: Partial<WebhookRegistration>): Promise<WebhookRegistration>;
  delete(id: string): Promise<void>;
  list(tenantId: string): Promise<WebhookRegistration[]>;

  // Event dispatching (intern, aangeroepen door andere services)
  dispatch(event: WebhookEvent, tenantId: string, data: Record<string, unknown>): Promise<void>;

  // Delivery monitoring
  getDeliveryLog(webhookId: string, limit?: number): Promise<WebhookDeliveryLog[]>;
  retryDelivery(deliveryLogId: string): Promise<void>;
}

Database schema

-- Webhook registraties
CREATE TABLE webhook_registrations (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  url             TEXT NOT NULL,
  events          TEXT[] NOT NULL,
  secret          TEXT NOT NULL,            -- HMAC signing secret (encrypted at rest)
  active          BOOLEAN DEFAULT TRUE,
  description     TEXT,
  headers         JSONB DEFAULT '{}',
  retry_max       INT DEFAULT 5,
  retry_backoff   INT DEFAULT 1000,
  created_by      UUID NOT NULL REFERENCES users(id),
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  updated_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Delivery log (voor monitoring en retry)
CREATE TABLE webhook_delivery_log (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  webhook_id      UUID NOT NULL REFERENCES webhook_registrations(id),
  event           TEXT NOT NULL,
  payload         JSONB NOT NULL,
  response_status INT,
  response_body   TEXT,
  attempt         INT NOT NULL DEFAULT 1,
  delivered_at    TIMESTAMPTZ,
  next_retry_at   TIMESTAMPTZ,
  error           TEXT,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Partitie delivery log per maand (groeit snel)
-- CREATE TABLE webhook_delivery_log_2026_02 PARTITION OF webhook_delivery_log
--   FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE INDEX idx_webhook_delivery_pending
  ON webhook_delivery_log (next_retry_at)
  WHERE delivered_at IS NULL AND next_retry_at IS NOT NULL;

-- RLS
ALTER TABLE webhook_registrations ENABLE ROW LEVEL SECURITY;
ALTER TABLE webhook_delivery_log ENABLE ROW LEVEL SECURITY;

CREATE POLICY webhook_reg_tenant ON webhook_registrations
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

Beveiliging

// Webhook signing — HMAC-SHA256
import crypto from 'node:crypto';

function signPayload(payload: WebhookPayload, secret: string): string {
  const body = JSON.stringify(payload);
  return crypto.createHmac('sha256', secret).update(body).digest('hex');
}

// Headers bij elke delivery:
// X-Webhook-Id: <registration id>
// X-Webhook-Event: report.created
// X-Webhook-Signature: sha256=<hmac>
// X-Webhook-Timestamp: <unix timestamp>

Integratiepunten

Bestaand component Integratie
C15 Rapportage Service report.created/updated/deleted → dispatch
C17 Agenda Service appointment.* → dispatch
C18 Behandelplan Service treatment_plan.* → dispatch
C35 Workflow Engine workflow.state_changed → dispatch
C38 Scheduled Jobs Retry queue voor gefaalde deliveries
C40-C42 Externe integraties Ontvangen events via webhook registraties

Belangrijk: geen PII in payloads

Webhook payloads bevatten alleen IDs en metadata, nooit PII:

// GOED:
{ event: 'report.created', data: { reportId: 'uuid', patientId: 'uuid', type: 'observatie' } }

// FOUT (nooit):
{ event: 'report.created', data: { patientName: 'Jan Janssen', bsn: '123456789', content: '...' } }

De ontvanger haalt volledige data op via de API met eigen authenticatie.


C40 — ZPM Facturatie Service

Doel

Voorbereiding op Zorgprestatiemodel (ZPM) facturatie: registratie van zorgprestaties, koppeling aan DBC-trajecten, en generatie van facturatiebatches. ZPM is sinds 2022 het bekostigingssysteem voor GGZ.

Waarom nodig

  • Wettelijk verplicht: Elke GGZ-instelling moet factureren volgens ZPM
  • Zorgprestaties: Elke behandelactiviteit (consult, groepssessie, diagnostiek) → prestatiecode
  • DBC-trajecten: Patiënt heeft een lopend DBC (Diagnose Behandel Combinatie) traject
  • Facturatiebatch: Maandelijks → declaratiebericht naar zorgverzekeraar (via Vecozo)

Interface

// lib/facturatie/types.ts

// NZa prestatiecode structuur
interface ZorgPrestatie {
  id: string;
  tenantId: string;
  patientId: string;
  dbcTrajectId: string;
  prestatieCode: string;                   // NZa code, bv. "OVP-0001"
  prestatieType: 'consult' | 'groep' | 'diagnostiek' | 'verblijf' | 'crisis' | 'overig';
  beroep: string;                          // BIG-registratie beroep behandelaar
  duur: number;                            // Minuten (voor directe tijd)
  indirecteTijd?: number;                  // Minuten indirect
  datum: Date;
  behandelaarId: string;
  appointmentId?: string;                  // Koppeling aan agenda
  reportId?: string;                       // Koppeling aan rapportage
  status: 'concept' | 'definitief' | 'gedeclareerd' | 'betaald' | 'afgewezen';
  metadata?: Record<string, unknown>;
}

interface DBCTraject {
  id: string;
  tenantId: string;
  patientId: string;
  diagnoseCode: string;                    // DSM-5 code
  zorgtype: string;                        // Initieel / vervolg
  startDatum: Date;
  eindDatum?: Date;
  status: 'open' | 'gesloten' | 'afgesloten_declaratie';
  verzekeringId: string;                   // Verwijzing naar verzekeringgegevens
}

interface DeclaratieBatch {
  id: string;
  tenantId: string;
  periode: string;                         // "2026-02"
  prestaties: string[];                    // Prestatie IDs
  totaalBedrag: number;                    // Euro's
  status: 'concept' | 'gecontroleerd' | 'verstuurd' | 'verwerkt' | 'deels_afgewezen';
  vecozo_referentie?: string;              // Na verzending
  gegenereerd_op: Date;
  verstuurd_op?: Date;
}

// Service interface
interface FacturatieService {
  // Prestatie registratie
  registreerPrestatie(prestatie: Omit<ZorgPrestatie, 'id' | 'status'>): Promise<ZorgPrestatie>;
  koppelAanAfspraak(prestatieId: string, appointmentId: string): Promise<void>;
  koppelAanRapportage(prestatieId: string, reportId: string): Promise<void>;

  // DBC beheer
  openTraject(traject: Omit<DBCTraject, 'id' | 'status'>): Promise<DBCTraject>;
  sluitTraject(trajectId: string): Promise<void>;

  // Facturatie
  genereerBatch(tenantId: string, periode: string): Promise<DeclaratieBatch>;
  controleerBatch(batchId: string): Promise<ValidationResult[]>;   // Controle regels
  markeerVerstuurd(batchId: string, vecozoRef: string): Promise<void>;

  // Overzichten
  getOpenPrestaties(tenantId: string, filters?: object): Promise<ZorgPrestatie[]>;
  getTrajecten(patientId: string): Promise<DBCTraject[]>;
  getOmzetOverzicht(tenantId: string, periode: string): Promise<OmzetOverzicht>;
}

interface ValidationResult {
  prestatieId: string;
  valid: boolean;
  errors: string[];                        // NL foutmeldingen
  warnings: string[];
}

interface OmzetOverzicht {
  periode: string;
  totaalGedeclareerd: number;
  totaalBetaald: number;
  totaalAfgewezen: number;
  totaalOpen: number;
  perBeroepsgroep: Record<string, number>;
  perZorgtype: Record<string, number>;
}

Database schema

-- DBC trajecten
CREATE TABLE dbc_trajecten (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  patient_id      UUID NOT NULL REFERENCES patients(id),
  diagnose_code   TEXT NOT NULL,
  zorgtype        TEXT NOT NULL DEFAULT 'initieel',
  start_datum     DATE NOT NULL,
  eind_datum      DATE,
  status          TEXT NOT NULL DEFAULT 'open',
  verzekering_id  UUID,                    -- Toekomstig: verzekeringsgegevens tabel
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  updated_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Zorgprestaties
CREATE TABLE zorgprestaties (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  patient_id      UUID NOT NULL REFERENCES patients(id),
  dbc_traject_id  UUID NOT NULL REFERENCES dbc_trajecten(id),
  prestatie_code  TEXT NOT NULL,
  prestatie_type  TEXT NOT NULL,
  beroep          TEXT NOT NULL,
  duur_minuten    INT NOT NULL,
  indirecte_tijd  INT DEFAULT 0,
  datum           DATE NOT NULL,
  behandelaar_id  UUID NOT NULL REFERENCES users(id),
  appointment_id  UUID REFERENCES appointments(id),
  report_id       UUID REFERENCES reports(id),
  status          TEXT NOT NULL DEFAULT 'concept',
  metadata        JSONB DEFAULT '{}',
  created_at      TIMESTAMPTZ DEFAULT NOW(),
  updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_zorgprestaties_patient ON zorgprestaties (patient_id, datum DESC);
CREATE INDEX idx_zorgprestaties_status ON zorgprestaties (tenant_id, status);
CREATE INDEX idx_zorgprestaties_traject ON zorgprestaties (dbc_traject_id);

-- Declaratie batches
CREATE TABLE declaratie_batches (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL REFERENCES tenants(id),
  periode         TEXT NOT NULL,            -- "2026-02"
  prestatie_ids   UUID[] NOT NULL,
  totaal_bedrag   DECIMAL(10,2),
  status          TEXT NOT NULL DEFAULT 'concept',
  vecozo_ref      TEXT,
  gegenereerd_op  TIMESTAMPTZ DEFAULT NOW(),
  verstuurd_op    TIMESTAMPTZ,
  metadata        JSONB DEFAULT '{}'
);

-- RLS
ALTER TABLE dbc_trajecten ENABLE ROW LEVEL SECURITY;
ALTER TABLE zorgprestaties ENABLE ROW LEVEL SECURITY;
ALTER TABLE declaratie_batches ENABLE ROW LEVEL SECURITY;

CREATE POLICY dbc_tenant ON dbc_trajecten
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY zorgprestaties_tenant ON zorgprestaties
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY declaratie_tenant ON declaratie_batches
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

Integratiepunten

Bestaand component Integratie
C17 Agenda Service Afspraak → automatisch zorgprestatie concept aanmaken
C15 Rapportage Service Rapport met contactmoment → koppelen aan prestatie
C25 RBAC + ABAC Alleen financieel medewerker mag batches genereren
C26 Audit Trail Elke facturatie-actie → audit log
C35 Workflow Engine Declaratie workflow: concept → controle → verzending
C38 Scheduled Jobs Maandelijkse batch generatie
C41 Vecozo Declaratiebatch versturen naar Vecozo (toekomstig)

Controleregels

// lib/facturatie/validatie.ts

// Voorbeelden van validatieregels die bij batch-controle draaien:
const validatieRegels = [
  // Geen prestatie zonder open DBC traject
  'prestatie.datum BETWEEN dbc.start_datum AND COALESCE(dbc.eind_datum, NOW())',
  // Maximaal 12 uur directe tijd per dag per behandelaar
  'SUM(duur_minuten) per behandelaar per dag <= 720',
  // Prestatiecode moet geldig zijn voor beroep
  'prestatie_code IN (valid_codes_for_beroep)',
  // Geen dubbele prestatie (zelfde patient, dag, code, behandelaar)
  'UNIQUE(patient_id, datum, prestatie_code, behandelaar_id)',
];

C41 — Vecozo Connector (voorbereiding)

Doel

Interface-voorbereiding voor koppeling met Vecozo: het landelijke communicatiepunt voor declaraties, COV-checks (Controle Op Verzekeringsrecht), en machtigingen in de zorg.

Status: alleen interface — geen implementatie

Vecozo-koppeling vereist een formeel aansluittraject (UZI-certificaat, testomgeving, acceptatie). Dit component definieert alleen de interfaces zodat C40 (ZPM Facturatie) er straks naadloos op aan kan sluiten.

Interface

// lib/integrations/vecozo/types.ts

interface VecozoConnector {
  // COV-check: is patiënt verzekerd?
  checkVerzekering(params: {
    bsn: string;
    peildatum: Date;
  }): Promise<VerzekeringStatus>;

  // Declaratie versturen
  submitDeclaratie(params: {
    batchId: string;
    declaratieXml: string;                 // Vektis EI-standaard
  }): Promise<DeclaratieResponse>;

  // Declaratie status opvragen
  getDeclaratieStatus(vecozoRef: string): Promise<DeclaratieStatusResponse>;

  // Machtiging aanvragen (voor bepaalde zorgtypen)
  requestMachtiging(params: {
    patientBsn: string;
    zorgtype: string;
    motivatie: string;
  }): Promise<MachtigingResponse>;

  // Health check
  isAvailable(): Promise<boolean>;
}

interface VerzekeringStatus {
  verzekerd: boolean;
  verzekeraar?: string;
  uzovi?: string;                          // UZOVI-code verzekeraar
  polisnummer?: string;
  ingangsDatum?: Date;
  eindDatum?: Date;
}

interface DeclaratieResponse {
  accepted: boolean;
  vecozoReferentie: string;
  errors?: string[];
}

interface DeclaratieStatusResponse {
  status: 'ontvangen' | 'in_behandeling' | 'goedgekeurd' | 'deels_afgewezen' | 'afgewezen';
  details?: string;
  betaalDatum?: Date;
}

interface MachtigingResponse {
  status: 'toegekend' | 'afgewezen' | 'in_behandeling';
  machtigingNummer?: string;
  geldigTot?: Date;
}

Vereisten voor aansluiting (niet nu, wel documenteren)

  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

// lib/integrations/vecozo/mock-connector.ts

export class MockVecozoConnector implements VecozoConnector {
  async checkVerzekering(params: { bsn: string; peildatum: Date }): Promise<VerzekeringStatus> {
    console.warn('[Vecozo] Mock connector — niet verbonden met Vecozo');
    return {
      verzekerd: true,
      verzekeraar: 'Mock Verzekeraar',
      uzovi: '0000',
    };
  }

  async submitDeclaratie(): Promise<DeclaratieResponse> {
    throw new Error('[Vecozo] Declaratie versturen niet beschikbaar — Vecozo connector niet geconfigureerd');
  }

  // ... etc
}

C42 — Zorgdomein Connector (voorbereiding)

Doel

Interface-voorbereiding voor koppeling met Zorgdomein: het landelijke platform voor elektronische verwijzingen van huisarts naar GGZ.

Status: alleen interface — geen implementatie

Net als Vecozo vereist Zorgdomein een formeel aansluittraject. Dit component definieert de interfaces.

Interface

// lib/integrations/zorgdomein/types.ts

interface ZorgdomeinConnector {
  // Nieuwe verwijzingen ophalen
  getNewReferrals(since: Date): Promise<ZorgdomeinVerwijzing[]>;

  // Verwijzing accepteren/afwijzen
  acceptReferral(verwijzingId: string): Promise<void>;
  rejectReferral(verwijzingId: string, reden: string): Promise<void>;

  // Status terugkoppelen
  updateReferralStatus(verwijzingId: string, status: string): Promise<void>;

  // Health check
  isAvailable(): Promise<boolean>;
}

interface ZorgdomeinVerwijzing {
  id: string;
  verwijzerAgbCode: string;                // Huisarts AGB-code
  verwijzerNaam: string;
  patientBsn: string;
  patientNaam: string;
  verwijsDatum: Date;
  urgentie: 'normaal' | 'spoed';
  klacht: string;
  anamnese?: string;
  diagnose?: string;
  medicatie?: string;
  bijlagen?: { naam: string; type: string; url: string }[];
}

Integratie met intake workflow

// Wanneer Zorgdomein actief is:
// 1. C38 (Scheduled Jobs) pollt Zorgdomein elke 15 minuten voor nieuwe verwijzingen
// 2. Nieuwe verwijzing → automatisch patient aanmaken (of matchen op BSN)
// 3. C35 (Workflow Engine) start intake_ggz workflow
// 4. C34 (Notificatie) meldt aan triagist: "Nieuwe verwijzing via Zorgdomein"

C43 — BI Module (voorbereiding)

Doel

Business Intelligence laag: materialized views, data-export en voorbereiding op dashboards voor management rapportages. Niet een volledige BI-tool, maar de datalaag die BI mogelijk maakt.

Waarom nodig

  • Stuurinformatie: Productiecijfers per behandelaar, team, afdeling
  • Wachttijden: Gemiddelde wachttijd aanmelding → intake (Treeknormen)
  • Kwaliteitsindicatoren: ROM-scores, uitval, no-shows
  • Financieel: Omzet per periode, ongedeclareerde prestaties
  • Bezetting: Bed/verblijfsbezetting, personeel/patiënt ratio

Interface

// lib/bi/types.ts

interface BIQuery {
  tenantId: string;
  metric: BIMetric;
  dimensions?: BIDimension[];
  dateRange: { from: Date; to: Date };
  filters?: Record<string, string | string[]>;
}

type BIMetric =
  | 'productie_uren'
  | 'aantal_consulten'
  | 'wachttijd_intake'
  | 'no_show_percentage'
  | 'bezettingsgraad'
  | 'omzet'
  | 'ongedeclareerd'
  | 'gemiddelde_behandelduur'
  | 'uitval_percentage'
  | 'incidenten_per_maand';

type BIDimension =
  | 'behandelaar'
  | 'team'
  | 'afdeling'
  | 'locatie'
  | 'zorgtype'
  | 'diagnose_groep'
  | 'maand'
  | 'week';

interface BIResult {
  metric: BIMetric;
  dimensions: Record<string, string>;
  value: number;
  previousValue?: number;                  // Vorige periode voor vergelijking
  trend?: 'up' | 'down' | 'stable';
}

interface BIService {
  query(query: BIQuery): Promise<BIResult[]>;
  refreshViews(tenantId: string): Promise<void>;
  exportCSV(query: BIQuery): Promise<string>;  // CSV content
  getAvailableMetrics(): BIMetric[];
}

Materialized views

-- Productie per behandelaar per maand
CREATE MATERIALIZED VIEW mv_productie_per_behandelaar AS
SELECT
  z.tenant_id,
  z.behandelaar_id,
  u.display_name AS behandelaar_naam,
  d.name AS afdeling,
  DATE_TRUNC('month', z.datum) AS maand,
  COUNT(*) AS aantal_prestaties,
  SUM(z.duur_minuten) AS directe_minuten,
  SUM(z.indirecte_tijd) AS indirecte_minuten,
  SUM(z.duur_minuten + COALESCE(z.indirecte_tijd, 0)) AS totaal_minuten
FROM zorgprestaties z
JOIN users u ON u.id = z.behandelaar_id
LEFT JOIN departments d ON d.id = u.department_id
WHERE z.status != 'concept'
GROUP BY z.tenant_id, z.behandelaar_id, u.display_name, d.name, DATE_TRUNC('month', z.datum);

CREATE UNIQUE INDEX idx_mv_productie
  ON mv_productie_per_behandelaar (tenant_id, behandelaar_id, maand);

-- Wachttijden (aanmelding → intake)
CREATE MATERIALIZED VIEW mv_wachttijden AS
SELECT
  wi.tenant_id,
  DATE_TRUNC('month', wi.created_at) AS maand,
  wi.current_state,
  COUNT(*) AS aantal,
  AVG(EXTRACT(DAY FROM (
    CASE WHEN wtl.to_state = 'triage'
    THEN wtl.created_at ELSE NULL END
  ) - wi.created_at)) AS gem_dagen_tot_triage,
  AVG(EXTRACT(DAY FROM (
    CASE WHEN wtl.to_state = 'gestart'
    THEN wtl.created_at ELSE NULL END
  ) - wi.created_at)) AS gem_dagen_tot_start
FROM workflow_instances wi
LEFT JOIN workflow_transitions_log wtl ON wtl.instance_id = wi.id
WHERE wi.entity_type = 'intake'
GROUP BY wi.tenant_id, DATE_TRUNC('month', wi.created_at), wi.current_state;

-- No-show percentage per maand
CREATE MATERIALIZED VIEW mv_no_shows AS
SELECT
  a.tenant_id,
  DATE_TRUNC('month', a.start_time) AS maand,
  COUNT(*) AS totaal_afspraken,
  COUNT(*) FILTER (WHERE a.status = 'no_show') AS no_shows,
  ROUND(
    COUNT(*) FILTER (WHERE a.status = 'no_show')::DECIMAL /
    NULLIF(COUNT(*), 0) * 100, 1
  ) AS no_show_percentage
FROM appointments a
WHERE a.start_time < NOW()
GROUP BY a.tenant_id, DATE_TRUNC('month', a.start_time);

-- Refresh via scheduled job (C38)
-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_productie_per_behandelaar;
-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_wachttijden;
-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_no_shows;

Integratiepunten

Bestaand component Integratie
C17 Agenda Service No-show data, bezettingsdata
C35 Workflow Engine Wachttijden (intake doorlooptijden)
C37 Caching BI resultaten cachen (namespace api:dashboard)
C38 Scheduled Jobs REFRESH MATERIALIZED VIEW CONCURRENTLY elke 15-60 min
C40 ZPM Facturatie Omzet en productiecijfers
C25 RBAC + ABAC Alleen management/admin rollen mogen BI queries draaien

Toekomstige uitbreiding

Als BI-behoeften groeien voorbij wat materialized views aankunnen:

  • Metabase (self-hosted, open source) als BI frontend bovenop dezelfde PostgreSQL
  • dbt voor data transformaties als de views complexer worden
  • Export naar extern data warehouse indien nodig

Bijgewerkt Component Overzicht (volledig)

LAAG 1: INTERFACE
├── C01  Traditionele EPD UI
├── C02  Intent Command Center
├── C03  Spraak Input
└── C04  Block/Artifact systeem

LAAG 2: INTENT SYSTEEM
├── C05  Intent Registry
├── C06  Reflex Classifier
├── C07  Orchestrator
├── C08  Entity Extractor + Date Parser
├── C09  Action System
└── C10  Protocol Engine / Nudge

LAAG 3: KNOWLEDGE LAYER
├── C11  Protocol Rules Store
├── C12  RAG Pipeline
├── C13  Regelvalidatie UI
└── C14  Kennisbron Connectors

LAAG 4: DOMEIN / BUSINESS LOGIC
├── C15  Rapportage Service
├── C16  Overdracht Service
├── C17  Agenda Service
├── C18  Behandelplan Service
├── C19  Medicatie Service
├── C20  Intake Service
├── C21  Patiënt Service
└── C22  Toestemming Service

LAAG 5: ENTERPRISE FUNDAMENT
├── C23  Auth + SSO (Keycloak)
├── C24  Multi-tenancy (RLS)
├── C25  RBAC + ABAC Engine
├── C26  Audit Trail (NEN 7513)
├── C27  Field Encryption
├── C28  FHIR Export Service
├── C29  CDS Hooks Service
├── C30  Observability
├── C31  Circuit Breaker / Resilience
├── C32  Health Check Service
└── C33  PII Filter

LAAG 6: PLATFORM SERVICES ← NIEUW
├── C34  Notificatie Service
├── C35  Workflow Engine (State Machine)
├── C36  Full-Text Search Service
├── C37  Caching Layer
├── C38  Scheduled Jobs Service
└── C39  Webhook System

LAAG 7: EXTERNE INTEGRATIES ← NIEUW
├── C40  ZPM Facturatie Service
├── C41  Vecozo Connector (voorbereiding)
└── C42  Zorgdomein Connector (voorbereiding)

LAAG 8: ANALYTICS ← NIEUW
└── C43  BI Module (materialized views)

TOTAAL: 43 componenten (33 bestaand + 10 nieuw)

Bijgewerkt Bouwplan (fasen)

De 10 nieuwe componenten passen als volgt in het bestaande 5-fasen bouwplan:

FASE 1: FUNDAMENT (week 1-4) — uitbreiding
├── (bestaand) PostgreSQL, Keycloak, Drizzle, Docker, Caddy
├── (bestaand) Multi-tenancy, Audit Trail, RBAC
├── C38 Scheduled Jobs ← NIEUW (fundament voor alles)
└── C37 Caching Layer ← NIEUW (Redis al aanwezig voor BullMQ)

FASE 2: EPD CORE (week 5-10) — uitbreiding
├── (bestaand) Rapportage, Overdracht, Agenda, Behandelplan
├── (bestaand) Intent systeem, Reflex, Orchestrator
├── C34 Notificatie Service ← NIEUW
├── C35 Workflow Engine ← NIEUW
└── C40 ZPM Facturatie ← NIEUW (basisregistratie, geen Vecozo)

FASE 3: KNOWLEDGE LAYER (week 11-14) — uitbreiding
├── (bestaand) Protocol Rules, RAG Pipeline, Nudge
├── C36 Full-Text Search ← NIEUW
└── C43 BI Module ← NIEUW (materialized views aanmaken)

FASE 4: CONNECTIVITEIT (week 15-18) — uitbreiding
├── (bestaand) FHIR Connector, Connector admin UI
├── C39 Webhook System ← NIEUW
├── C41 Vecozo Connector ← NIEUW (interface + mock, geen live)
└── C42 Zorgdomein Connector ← NIEUW (interface + mock, geen live)

FASE 5: COMPLIANCE & HARDENING (week 19-22)
├── (bestaand) Encryption, Wvggz, Observability, Load test, Pentest
└── Vecozo/Zorgdomein live aansluiting (indien traject loopt)

Beslissingenlog (aanvullingen)

# Beslissing Alternatieven Rationale
D20 Notificaties via SSE (geen WebSockets) WebSockets, polling Eenvoudiger, minder infra, notificaties zijn unidirectioneel
D21 Eigen state machine (geen XState) XState, Temporal.io ~200 regels code, server-side, geen dependency bloat
D22 PostgreSQL tsvector (geen Elasticsearch) Elasticsearch, Meilisearch Geen extra service, voldoende voor GGZ-schaal
D23 Materialized views voor BI (geen apart warehouse) Metabase direct, dbt, Snowflake Start simpel, upgrade later als nodig
D24 Webhook signing met HMAC-SHA256 JWT, API keys Industriestandaard (GitHub, Stripe), simpel te valideren
D25 Geen PII in webhook payloads Encrypted payloads Defense in depth, ontvanger haalt data via API

Appendix: Cross-referentie nieuwe componenten × use cases

Use Case C34 C35 C36 C37 C38 C39 C40 C41 C42 C43
UC-01 Dagnotitie
UC-05 Overdracht
UC-07 Zoeken
UC-10 Afspraak
UC-13 Intake status
UC-20 Nudge
UC-21 Behandelplan
UC-22 Medicatie
UC-23 Incident
UC-24 Wvggz
UC-26 Audit log
UC-30 Dashboard
UC-32 Groepstherapie
UC-33 Break-the-glass

● = component is betrokken bij deze use case


Appendix: Relatie tussen alle architectuurdocumenten (bijgewerkt)

docs/architecture/
│
├── intent-system-architectuur-nl.md
│   Beschrijft: Hoe het intent systeem intern werkt
│   Scope: 5 bouwblokken, classificatie, entity resolution, nudge
│
├── enterprise-epd-architectuur.md
│   Beschrijft: Enterprise EPD schil (multi-tenancy, audit, security)
│   Stack: Supabase + Vercel (VERVANGEN door platform-connectiviteit-selfhosted.md)
│   Status: Functioneel nog geldig, stack is gewijzigd
│
├── usecases-technische-componenten.md
│   Beschrijft: 33 use cases × 34 componenten validatie
│   Scope: Gap analyse, bouwvolgorde, kruistabel
│   Status: Uitgebreid met 10 nieuwe componenten (zie dit document)
│
├── platform-connectiviteit-selfhosted.md
│   Beschrijft: Self-hosted stack, platform splitsing, connectors
│   Vervangt: Stack keuzes uit enterprise-epd-architectuur.md
│   Voegt toe: Connector API, deployment modellen, Keycloak, Drizzle
│
└── enterprise-componenten-uitbreiding.md  ← DIT DOCUMENT
    Beschrijft: 10 nieuwe componenten (C34C43) als voorbereiding
    Scope: Interfaces, database schemas, integratiepunten, fasering
    Status: Voorbereiding — nog niet gebouwd