Skip to content
Updated Jun 9, 2026

Notifications

olly-notifications (port :4006) turns domain events into member messages. It consumes Kafka events from claims, billing and enrollment, resolves the party's contact details and channel preference, renders a Go-side template, and records the outcome in a notification log. It is a self-contained dispatcher: there is no workflow engine, no external delivery provider, and no separate template store.

Delivery is stubbed

The email and SMS senders (internal/dispatch/email.go, internal/dispatch/sms.go) currently log the message and return success without actually sending. The POST /send route is a no-op that returns 202 Accepted. The full pipeline (consume → resolve → render → log) runs end to end; only the final transport call is a placeholder pending a real SMTP/SMS provider.

Field reference: full columns, types and nullability live in the catalog: glossary terms NotificationLog, NotificationPreferences. This page is the narrative.

What this service owns

ObjectWhere storedNotes
NotificationLogPostgres notifications schemaOne row per consumed event: rendered subject/body, channel, status, failure reason
NotificationPreferencesPostgres notifications schemaPer-party preferred channel + opted-out event list
projection_checkpointsPostgres notifications schemaKafka offset state per (topic, partition)

Contact details (email, phone) are not owned here. They are resolved on demand from policy-admin and copied onto the log entry only as needed. Templates are not stored in the database: they are a fixed Go map in internal/dispatch/templates.go.

The dispatch flow

Per consumed event (internal/dispatch/dispatcher.go):

  1. Resolve contact. Call policy-admin (GET /internal/parties/{partyLocator}) for email + phone. A resolution error logs FAILED; a 404 logs SKIPPED ("party not found").
  2. Pick channel. Default EMAIL. If a NotificationPreferences row exists, use its preferred_channel. If the event type is in opted_out_events, log SKIPPED and stop.
  3. Guard the channel. EMAIL with no email on file, or SMS with no phone, logs SKIPPED.
  4. Render. Look up the event type in the template map and fmt.Sprintf the locator (and reason, for claim.rejected) into the body. An unknown event type falls back to a generic "A {eventType} event has occurred" message rather than failing.
  5. Send. Call the matching stub sender. IN_APP only logs (the message is considered stored by virtue of the log row).
  6. Log. Write a notification_log row with status SENT, FAILED, or SKIPPED.

Idempotency

The dedup key is the event's eventId, enforced in Postgres, not in a cache. notification_log.event_id has a UNIQUE index (migration 0007) and inserts run INSERT ... ON CONFLICT (event_id) DO NOTHING (repository/gorm_log.go). A redelivered Kafka message produces no second log row and no second send. There is no Valkey/Redis dependency and no TTL window: dedup is permanent for the lifetime of the row.

Retry

A background job (internal/job/retry.go, poll interval hardcoded to 60s; the RETRY_INTERVAL env var is parsed but not wired) selects FAILED rows newer than RETRY_MAX_AGE (default 24h) with attempt_count < RETRY_MAX_ATTEMPTS (default 3), re-resolves the contact, and re-sends from the stored subject/body, bumping attempt_count. Because the senders are stubs, retries currently always flip a row to SENT.

API routes

There is no /v1/ prefix and no versioning in the path. All routes except /healthz, /readyz and /send sit behind Keycloak JWT validation when KEYCLOAK_JWKS_URI is set; with no JWKS URI configured they are unauthenticated.

MethodPathNotes
POST/sendNo-op stub. Returns 202 {"status":"accepted"}; does not enqueue or send anything.
GET/preferences/{partyLocator}Fetch a party's preferences; 404 if none
PUT/preferences/{partyLocator}Upsert email/phone/preferredChannel/optedOutEvents; validates channel ∈
GET/preferences/listAdmin list of all preference rows
GET / PUT/preferencesv2 boolean stubs. GET returns an empty boolean object; PUT echoes the request body. Neither reads or persists anything.
GET/log/list, /notifications/listList log entries (filter by partyLocator, eventType, status)
GET/log/{locator}, /notifications/{locator}Fetch one log entry by NTF-… locator
GET/healthz, /readyzLiveness; readiness pings the DB

/log/* and /preferences/list are the web-admin views (reached via APISIX). /log/{locator} and /notifications/{locator} resolve the same handler.

Database

Schema notifications, connected via DATABASE_URL (there is no NOTIFICATIONS_DSN). Three tables.

TableKey columnsNotes
notification_loglocator UNIQUE, event_id UNIQUE, party_locator, event_type, channel, status, subject, body, failure_reason, attempt_count, sent_atOne row per consumed event. event_id UNIQUE is the idempotency key. Stores rendered subject/body, not the raw event payload.
notification_preferencesparty_locator UNIQUE, email, phone, preferred_channel (DEFAULT EMAIL), opted_out_events jsonbA single preferred channel plus a list of opted-out event types. No per-event channel matrix.
projection_checkpointsid uuid PK, UNIQUE(topic, partition_id), "offset", processed_atKafka offset state. Keyed on (topic, partition), no consumer_group column.

Events

Consumes

Consumer group notifications-service, reading three coarse topics: claims, billing, enrollment (KAFKA_TOPICS, KAFKA_GROUP_ID). Routing is by the eventType field inside the message envelope, matched against the in-code template map. There is no event_to_workflow table and no per-dotted-topic subscription.

DomaineventType keys with a template
Enrollmentpolicy.issued, policy.cancelled, policy.reinstated, policy.lapsed, policy.renewed, policy.endorsed
Claimsclaim.submitted, claim.review_required, claim.approved, claim.rejected, claim.info_requested, claim.paid
Billinginvoice.finalised, invoice.paid, invoice.overdue, payment.received, payment.void, charge.void, adjustment.applied, adjustment.reversed

An eventType outside this set still produces a log row using the generic fallback template. Consent events are not consumed.

Produces

None. The service is a sink: it writes only to its own log table.

Enums

Authoritative values live in packages/go/domain/enums.go.

  • NotificationStatus: SENT · FAILED · SKIPPED. There is no deduped, triggered, or novu_error status; a deduplicated event simply produces no new row.
  • NotificationChannel: EMAIL · SMS · IN_APP. There is no push channel.

Dependencies

DependencyConfig env varUsed for
Postgres notificationsDATABASE_URLLog, preferences, checkpoints
KafkaKAFKA_BROKERS, KAFKA_TOPICS, KAFKA_GROUP_IDEvent consumption
Policy Admin (policy-admin:8080)POLICY_ADMIN_URLpartyLocator → email/phone resolution
SMTP (stub)SMTP_HOST, SMTP_PORTHeld by the email sender but never dialed (sender is a stub)
Keycloak JWKSKEYCLOAK_JWKS_URIJWT validation on read routes (optional; routes are open if unset)
OTel collectorOTEL_ENDPOINTTracing

Invariants

  • Idempotency is the event_id UNIQUE constraint plus ON CONFLICT DO NOTHING. One consumed event maps to at most one log row.
  • Every consumed event produces exactly one notification_log row with status SENT, FAILED, or SKIPPED (or no row at all, if it is a duplicate event_id).
  • A party has at most one notification_preferences row (party_locator UNIQUE) with one preferred_channel; opt-out is per event type via opted_out_events, not per channel.
  • The Kafka offset checkpoint is saved after each message regardless of dispatch outcome (delivery at-least-once; the event_id constraint absorbs replays).
  • Templates are code, not data. Adding a notification type means adding a map entry and deploying, not inserting a row.

Caveats

  • No external provider. Despite the platform-level "Notifications: Novu" narrative and a separate Novu deployment on uat, this Go service contains zero Novu code and no NOVU_* config. It does not call Novu, SendGrid, Twilio, or any delivery API. The Novu-adapter direction is a plan, not the running service.
  • Senders are stubs. Email and SMS sends log and return nil; nothing leaves the process. SMTP env vars are read but unused.
  • /send does nothing. It is a hardcoded 202 and does not trigger a notification. Events arrive via Kafka, not this route.
  • v2 preference routes are placeholders. GET/PUT /preferences (boolean shape) neither read nor write the database.
  • No payload storage and no PII redaction. The log stores rendered subject/body only; there is no payload column and no redaction code. PII scrubbing on stored text is not implemented.
  • No subscriber-widget JWT issuance. The only JWT usage is inbound Keycloak validation; there is no HMAC-signing route for an in-app inbox.

Olly Health Insurance Platform