Developer Guide
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.
| Path | Responsibility |
|---|---|
src/router.ts | Routes for VAPID key, subscription, preferences, manual trigger, and the automated temperature push. |
src/controller/notificationController.ts | HTTP adapters; owns the temperature-alert throttle and request normalisation. |
src/services/notificationService.ts | Redis publishing to the notifications channel. |
src/services/pushService.ts | VAPID Web Push delivery, preference resolution, and stale-subscription cleanup. |
src/models/webSubscription.ts | WebPushSubscription schema (device endpoint + keys). |
src/models/notificationSubscription.ts | NotificationSubscription schema (per-domain opt-in preferences). |
src/config/redis.ts | Shared 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| Variable | Default | Purpose |
|---|---|---|
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY | empty | VAPID key pair; when both are set, web-push is configured for delivery. |
GATEWAY_URL | http://localhost:3000 | Internal gateway base; used to resolve a building’s domains via the twin-service. |
REDIS_URL | — | Broker connection for publishing notifications. |
DEMO_NOTIFICATION_DOMAIN_ID | — | When set, the optional demo loop pushes a periodic test alert to that domain. |
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.
| Method · Path | Auth | Description |
|---|---|---|
GET /public-key | Public | Returns { publicVapidKey } for the browser to create a push subscription. |
POST /subscribe | JWT | Upserts a device subscription for the authenticated account (bound from the token) and, when a domain is supplied, the matching preferences. 201. |
GET /preferences/:accountName | JWT | Returns the caller’s own NotificationSubscription records. The :accountName param is ignored (identity from the token). |
POST /preferences | JWT | Sets one or more per-domain, per-type opt-in flags for the caller. 200. |
POST /trigger | JWT | Manual alert for a building (no cooldown). Resolves domains via twin (forwarding the caller’s token), publishes, and pushes. 200. |
POST /push/temperature | JWT | Automated 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.
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 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)
endThe key is temp_alert:{buildingId}:{roomId}; the cooldown is 300 seconds. After it expires, the next breach starts a new cycle.
pushService separates who to notify from how to reach them.
sendPushToDomain(payload, domainName, type) queries NotificationSubscription for records in the domain whose preferences contain an enabled entry for type ($elemMatch), reduces to a unique set of account names, and delegates to sendPushToUsers.sendPushToUsers(payload, accountNames) loads every WebPushSubscription for those accounts and sends the encrypted payload to each endpoint via web-push.publishNotification posts { id, message, type, timestamp, domainName? } to the Redis notifications channel, which the socket-service relays to browsers.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.
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.
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.
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.