# 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<string, unknown>;
  expiresAt?: Date;                        // Automatisch opruimen
}

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

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

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

### Database schema

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

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

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

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

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

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

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

### Integratiepunten

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

### Technologiekeuze

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

### Trade-offs

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

---

## C35 — Workflow Engine (State Machine)

### Doel

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

### Waarom nodig

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

### Interface

```typescript
// lib/workflow/types.ts

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

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

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

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

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

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

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

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

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

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

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

### Database schema

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

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

CREATE INDEX idx_workflow_instances_entity
  ON workflow_instances (entity_type, entity_id);

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

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

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

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

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

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

### Voorgedefinieerde workflows

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

### Integratiepunten

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

### Technologiekeuze

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

---

## C36 — Full-Text Search Service

### Doel

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

### Waarom nodig

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

### Interface

```typescript
// lib/search/types.ts

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

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

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

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

### Database schema

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

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

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

CREATE INDEX idx_reports_search ON reports USING GIN (search_vector);

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

CREATE INDEX idx_patients_search ON patients USING GIN (search_vector);

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

### Integratiepunten

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

### Technologiekeuze

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

### Trade-offs

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

### Schaalgrens

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

---

## C37 — Caching Layer

### Doel

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

### Waarom nodig

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

### Interface

```typescript
// lib/cache/types.ts

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

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

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

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

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

### TTL strategie

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

### Implementatie

```typescript
// lib/cache/redis-cache.ts

import Redis from 'ioredis';

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

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

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

### Integratiepunten

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

### Technologiekeuze

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

### Geen gevoelige data in cache

BSN, diagnoses en andere PII worden **nooit** direct gecached. Alleen:
- Hashed keys met verwijzing naar encrypted database records
- AI-gegenereerde samenvattingen (geen ruwe patiëntdata)
- Aggregaties en counts

---

## C38 — Scheduled Jobs Service

### Doel

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

### Waarom nodig

- **Audit trail**: Partities aanmaken, oude partities archiveren
- **Workflow deadlines**: Dagelijks checken of workflows hun `maxDurationDays` overschrijden
- **Notificatie digests**: Dagelijkse email samenvattingen
- **ZPM facturatie**: Maandelijkse facturatie batch
- **Protocol sync**: Periodiek nieuwe protocollen ophalen/indexeren
- **Caching**: Periodiek dashboard aggregaties vernieuwen

### Interface

```typescript
// lib/jobs/types.ts

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

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

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

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

### Geplande jobs

```typescript
// lib/jobs/definitions.ts

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

### Integratiepunten

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

### Technologiekeuze

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

---

## C39 — Webhook System

### Doel

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

### Waarom nodig

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

### Interface

```typescript
// lib/webhooks/types.ts

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

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

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

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

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

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

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

### Database schema

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

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

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

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

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

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

### Beveiliging

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

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

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

### Integratiepunten

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

### Belangrijk: geen PII in payloads

Webhook payloads bevatten **alleen IDs en metadata**, nooit PII:

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

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

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

---

## C40 — ZPM Facturatie Service

### Doel

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

### Waarom nodig

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

### Interface

```typescript
// lib/facturatie/types.ts

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

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

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

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

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

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

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

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

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

### Database schema

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

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

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

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

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

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

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

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

### Integratiepunten

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

### Controleregels

```typescript
// lib/facturatie/validatie.ts

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

---

## C41 — Vecozo Connector (voorbereiding)

### Doel

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

### Status: alleen interface — geen implementatie

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

### Interface

```typescript
// lib/integrations/vecozo/types.ts

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

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

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

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

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

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

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

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

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

### Vereisten voor aansluiting (niet nu, wel documenteren)

1. **UZI-certificaat**: Nodig voor mTLS communicatie met Vecozo
2. **AGB-code**: Instelling moet geregistreerd zijn
3. **Testomgeving**: Vecozo biedt een acceptatieomgeving
4. **Vektis standaarden**: Declaratieberichten in EI-standaard XML formaat
5. **BSN-verificatie**: Koppeling met BRP via Vecozo

### Placeholder implementatie

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

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

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

  // ... etc
}
```

---

## C42 — Zorgdomein Connector (voorbereiding)

### Doel

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

### Status: alleen interface — geen implementatie

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

### Interface

```typescript
// lib/integrations/zorgdomein/types.ts

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

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

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

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

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

### Integratie met intake workflow

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

---

## C43 — BI Module (voorbereiding)

### Doel

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

### Waarom nodig

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

### Interface

```typescript
// lib/bi/types.ts

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

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

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

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

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

### Materialized views

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

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

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

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

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

### Integratiepunten

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

### Toekomstige uitbreiding

Als BI-behoeften groeien voorbij wat materialized views aankunnen:
- **Metabase** (self-hosted, open source) als BI frontend bovenop dezelfde PostgreSQL
- **dbt** voor data transformaties als de views complexer worden
- Export naar extern data warehouse indien nodig

---

## Bijgewerkt Component Overzicht (volledig)

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

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

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

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

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

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

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

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

TOTAAL: 43 componenten (33 bestaand + 10 nieuw)
```

---

## Bijgewerkt Bouwplan (fasen)

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

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

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

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

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

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

---

## Beslissingenlog (aanvullingen)

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

---

## Appendix: Cross-referentie nieuwe componenten × use cases

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

● = component is betrokken bij deze use case

---

## Appendix: Relatie tussen alle architectuurdocumenten (bijgewerkt)

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