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
| Object | Where stored | Notes |
|---|---|---|
NotificationLog | Postgres notifications schema | One row per consumed event: rendered subject/body, channel, status, failure reason |
NotificationPreferences | Postgres notifications schema | Per-party preferred channel + opted-out event list |
projection_checkpoints | Postgres notifications schema | Kafka 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):
- Resolve contact. Call policy-admin (
GET /internal/parties/{partyLocator}) for email + phone. A resolution error logsFAILED; a 404 logsSKIPPED("party not found"). - Pick channel. Default
EMAIL. If aNotificationPreferencesrow exists, use itspreferred_channel. If the event type is inopted_out_events, logSKIPPEDand stop. - Guard the channel.
EMAILwith no email on file, orSMSwith no phone, logsSKIPPED. - Render. Look up the event type in the template map and
fmt.Sprintfthe locator (and reason, forclaim.rejected) into the body. An unknown event type falls back to a generic "A {eventType} event has occurred" message rather than failing. - Send. Call the matching stub sender.
IN_APPonly logs (the message is considered stored by virtue of the log row). - Log. Write a
notification_logrow with statusSENT,FAILED, orSKIPPED.
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.
| Method | Path | Notes |
|---|---|---|
POST | /send | No-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/list | Admin list of all preference rows |
GET / PUT | /preferences | v2 boolean stubs. GET returns an empty boolean object; PUT echoes the request body. Neither reads or persists anything. |
GET | /log/list, /notifications/list | List log entries (filter by partyLocator, eventType, status) |
GET | /log/{locator}, /notifications/{locator} | Fetch one log entry by NTF-… locator |
GET | /healthz, /readyz | Liveness; 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.
| Table | Key columns | Notes |
|---|---|---|
notification_log | locator UNIQUE, event_id UNIQUE, party_locator, event_type, channel, status, subject, body, failure_reason, attempt_count, sent_at | One row per consumed event. event_id UNIQUE is the idempotency key. Stores rendered subject/body, not the raw event payload. |
notification_preferences | party_locator UNIQUE, email, phone, preferred_channel (DEFAULT EMAIL), opted_out_events jsonb | A single preferred channel plus a list of opted-out event types. No per-event channel matrix. |
projection_checkpoints | id uuid PK, UNIQUE(topic, partition_id), "offset", processed_at | Kafka 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.
| Domain | eventType keys with a template |
|---|---|
| Enrollment | policy.issued, policy.cancelled, policy.reinstated, policy.lapsed, policy.renewed, policy.endorsed |
| Claims | claim.submitted, claim.review_required, claim.approved, claim.rejected, claim.info_requested, claim.paid |
| Billing | invoice.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 nodeduped,triggered, ornovu_errorstatus; a deduplicated event simply produces no new row.NotificationChannel:EMAIL·SMS·IN_APP. There is nopushchannel.
Dependencies
| Dependency | Config env var | Used for |
|---|---|---|
Postgres notifications | DATABASE_URL | Log, preferences, checkpoints |
| Kafka | KAFKA_BROKERS, KAFKA_TOPICS, KAFKA_GROUP_ID | Event consumption |
Policy Admin (policy-admin:8080) | POLICY_ADMIN_URL | partyLocator → email/phone resolution |
| SMTP (stub) | SMTP_HOST, SMTP_PORT | Held by the email sender but never dialed (sender is a stub) |
| Keycloak JWKS | KEYCLOAK_JWKS_URI | JWT validation on read routes (optional; routes are open if unset) |
| OTel collector | OTEL_ENDPOINT | Tracing |
Invariants
- Idempotency is the
event_idUNIQUE constraint plusON CONFLICT DO NOTHING. One consumed event maps to at most one log row. - Every consumed event produces exactly one
notification_logrow with statusSENT,FAILED, orSKIPPED(or no row at all, if it is a duplicateevent_id). - A party has at most one
notification_preferencesrow (party_locatorUNIQUE) with onepreferred_channel; opt-out is per event type viaopted_out_events, not per channel. - The Kafka offset checkpoint is saved after each message regardless of dispatch outcome (delivery at-least-once; the
event_idconstraint 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. /senddoes nothing. It is a hardcoded202and 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/bodyonly; there is nopayloadcolumn 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.
