/

Developer Guide

Notification Service

Implementation reference for the notification-service. For its architectural role, see Notification Service Architecture; for the domain model, see Alerting.

A Node.js / Express service. It receives candidate alert events, debounces them, resolves recipients, and delivers through two channels: an in-app event over the Redis broker and a Web Push to registered devices.


Source Layout

PathResponsibility
src/router.tsRoutes for VAPID key, subscription, preferences, manual trigger, and the automated temperature push.
src/controller/notificationController.tsHTTP adapters; owns the temperature-alert throttle and request normalisation.
src/services/notificationService.tsRedis publishing to the notifications channel.
src/services/pushService.tsVAPID Web Push delivery, preference resolution, and stale-subscription cleanup.
src/models/webSubscription.tsWebPushSubscription schema (device endpoint + keys).
src/models/notificationSubscription.tsNotificationSubscription schema (per-domain opt-in preferences).
src/config/redis.tsShared Redis client.
graph TD
    ROUTER["router.ts"] --> CTRL["controller/notificationController.ts\nthrottle, request normalisation"]
    CTRL --> NSV["services/notificationService.ts\nRedis publish"]
    CTRL --> PSV["services/pushService.ts\nWeb Push, preferences, cleanup"]
    PSV --> M1["models/webSubscription.ts"]
    PSV --> M2["models/notificationSubscription.ts"]
    NSV --> REDIS["config/redis.ts"]
    PSV --> REDIS

Configuration

VariableDefaultPurpose
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYemptyVAPID key pair; when both are set, web-push is configured for delivery.
GATEWAY_URLhttp://localhost:3000Internal gateway base; used to resolve a building’s domains via the twin-service.
REDIS_URLBroker connection for publishing notifications.
DEMO_NOTIFICATION_DOMAIN_IDWhen set, the optional demo loop pushes a periodic test alert to that domain.

Data Model

Two collections, kept separate by design (see Alerting.

// webSubscription.ts — "where can I reach this account?"
interface IWebPushSubscription {
  accountName: string;          // indexed
  endpoint: string;             // unique
  keys: { p256dh: string; auth: string };
}
// compound unique index { accountName, endpoint }

// notificationSubscription.ts — "what does this account want?"
interface INotificationSubscription {
  accountName: string;
  domainName: string;
  preferences: { notificationType: NotificationType; isSubscribed: boolean }[];
  createdAt: Date;
}
// compound unique index { accountName, domainName }

enum NotificationType { TEMPERATURE = "temperature" }

A single account may own several WebPushSubscription records (one per device) but exactly one NotificationSubscription per domain.


HTTP API

Method · PathAuthDescription
GET /public-keyPublicReturns { publicVapidKey } for the browser to create a push subscription.
POST /subscribeJWTUpserts a device subscription for the authenticated account (bound from the token) and, when a domain is supplied, the matching preferences. 201.
GET /preferences/:accountNameJWTReturns the caller’s own NotificationSubscription records. The :accountName param is ignored (identity from the token).
POST /preferencesJWTSets one or more per-domain, per-type opt-in flags for the caller. 200.
POST /triggerJWTManual alert for a building (no cooldown). Resolves domains via twin (forwarding the caller’s token), publishes, and pushes. 200.
POST /push/temperatureJWTAutomated threshold alert; subject to the per-room cooldown below. 200.

Request bodies accept the canonical domainName field and the deprecated alias domainId. The account is always taken from the verified JWT, never from a body accountName/userId or the URL param — this closed an IDOR on GET /preferences/:accountName. Preferences may be supplied as a single { type, enabled }, a types[] array with one enabled, or a granular preferences[] array.

Auth boundary

Only /health and /public-key (the non-secret VAPID key) stay public; requireAuthentication guards everything else. /trigger and /push/temperature fan out push notifications to a whole domain, so leaving them open was an abuse vector — they now require a valid token like the rest.


The Temperature-Alert Throttle

The sensor-service fires on every reading (~every 10 s). To avoid a storm of identical alerts, pushTemperatureAlert debounces per room using a Redis key with a fixed TTL.

sequenceDiagram
    autonumber
    participant SS as sensor-service
    participant NS as notification-service
    participant R as Redis
    participant TW as twin-service
    SS->>NS: POST /push/temperature { buildingId, roomId, temperature }
    NS->>R: GET temp_alert:{buildingId}:{roomId}
    alt cooldown active
        R-->>NS: "1"
        NS-->>SS: 200 (silently throttled)
    else first alert / expired
        opt domain not supplied
            NS->>TW: GET /twin/domain/:buildingId
            TW-->>NS: domain names
        end
        Note over NS: publish "notifications" + Web Push to opted-in recipients
        NS->>R: SET temp_alert:{buildingId}:{roomId} "1" EX 300
        NS-->>SS: 200 (dispatched)
    end

The key is temp_alert:{buildingId}:{roomId}; the cooldown is 300 seconds. After it expires, the next breach starts a new cycle.


Recipient Resolution and Delivery

pushService separates who to notify from how to reach them.

Preference upserts

setUserNotificationPreference is idempotent per type: it first $pulls any existing entry for that notificationType, then $pushes the new { notificationType, isSubscribed }, using $setOnInsert to create the document on first write.

Stale-subscription cleanup

When web-push returns 410 Gone or 403 Forbidden, the endpoint is permanently invalid and the subscription is deleted automatically (Subscription.deleteOne({ endpoint })). All other errors (network, 5xx) are logged but leave the subscription intact, as they may be transient.

Demo loop

startNotificationLoop publishes a periodic “System Status Check” (and, if DEMO_NOTIFICATION_DOMAIN_ID is set, a demo push) every 10 seconds. It is a development aid and should be disabled in production.


Error Handling

The service uses the shared BaseError hierarchy and global error middleware (see Node Auth & Error Middleware. Missing accountName/domainName and malformed push payloads raise ValidationError (400); a failed twin lookup surfaces as a 500.