Notifications Module — Storefront
HTTP surface for the customer app: register/unregister FCM device tokens, the in-app notification feed (bell/inbox with read/seen state, SSE live stream, images), the per-user notification preference matrix, and the public token-gated marketing-email unsubscribe. The in-app channel mirrors push.
HTTP surface for the customer mobile app to register and unregister its FCM device token for push notifications. Customer devices are tagged appKind: "customer" (distinct from the vendor app's appKind: "vendor") so broadcasts can target the right audience without leaking notifications across apps.
Source:
api-modules/notifications/src/controllers/store-device.controller.ts.Admin broadcast composition and log inspection live in
docs/separated/admin/notifications.md. The vendor device-register surface mirrors this one and lives indocs/separated/vendor/notifications.md.
Conventions
Authentication
| Endpoint | Auth |
|---|---|
POST /store/devices | required (customer session) |
DELETE /store/devices | required (customer session) |
Device registration is tied to the session user via session.user.id — there's no "register a device for someone else" surface.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Domain types
DeviceResponse
type DeviceResponse = {
id: string;
platform: "android" | "ios";
appKind: "customer"; // always "customer" on this surface
lastSeenAt: string; // ISO — refreshed on every re-register
};Endpoints
POST /store/devices — Register a customer device
Register or refresh an FCM registration token for the calling customer. Idempotent on (userId, token) — re-registering the same token refreshes lastSeenAt instead of creating a duplicate row. Stale tokens (where the FCM SDK has rotated the registration) are naturally swept by the next register call.
Body
{
"platform": "android", // or "ios"
"token": "fXyZ...firebase-registration-token..."
}| Field | Type | Constraints |
|---|---|---|
platform | enum | android / ios |
token | string | trimmed, 10..4096 chars |
Response 201 — DeviceResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Token too short / wrong platform value |
| 401 | UNAUTHORIZED | No customer session |
DELETE /store/devices — Unregister a device
Unregister an FCM token. Idempotent — unregistering an unknown or already-unregistered token still returns 200; the response body is { "ok": true }. Use this on user-initiated sign-out so the device stops receiving pushes.
Body
{ "token": "fXyZ...firebase-registration-token..." }| Field | Type | Constraints |
|---|---|---|
token | string | trimmed, 1..4096 chars |
Response 200
{ "data": { "ok": true }, "message": "Success", "statusCode": 200 }Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Empty token |
| 401 | UNAUTHORIZED | No customer session |
In-app notification feed
A persisted, per-user notification centre (the "bell"). The in-app channel mirrors push: whenever a push is sent to the customer, the same content is also written here as a durable, readable row (push is ephemeral; the feed is the record). Rows carry seen/read state and optional images.
Source:
api-modules/notifications/src/controllers/store-notification.controller.ts. Feed rows areappKind: "customer".
NotificationFeedItem
type NotificationFeedItem = {
id: string;
category: "orders" | "returns" | "payouts" | "affiliate" | "account" | "marketing" | "system";
eventType: string; // source domain event, e.g. "order.placed"
title: string;
body: string;
actionUrl: string | null; // deep-link target
icon: string | null;
images: { url: string; alt: string | null }[];
data: Record<string, string>; // deeplink ids, analytics tags
seen: boolean;
read: boolean;
createdAt: string; // ISO
};GET /store/notifications — Paginated feed (newest first)
Offset-paginated (buildPaginatedResponse); archived rows excluded. Query: shared querySchema (limit 1..500 default 100, offset) plus unreadOnly (boolean) and category. Response 200 — { data: NotificationFeedItem[], metadata: { total, limit, offset, hasMore } }.
GET /store/notifications/unread-count — Badge counts
Response 200 — { unread: number, unseen: number }. unseen drives the bell dot; unread the "N unread" label.
GET /store/notifications/stream — Live feed (SSE)
Server-Sent Events for the signed-in customer. Emits { type: "ready", unread, unseen } on connect, then { type: "notification", ... } per new feed row, plus { type: "heartbeat" } every 30s. @SkipResponseWrap — raw SSE, not the JSON envelope.
POST /store/notifications/:id/read — Mark one read
Returns { ok: true }. 404 when the id isn't the caller's (ownership-scoped — a foreign id is indistinguishable from a missing one).
POST /store/notifications/read-all — Mark all read
Returns { ok: true, updated: <count> }.
POST /store/notifications/seen — Clear the bell badge
Marks all rows seen (badge → 0) without marking them read. Returns { ok: true, updated: <count> }.
POST /store/notifications/:id/archive — Dismiss
Removes the row from the feed. Returns { ok: true }. 404 if not owned.
Notification preferences
A per-user opt-in matrix: for each category × channel (in_app / email / push) the user chooses on/off. Absent entries fall back to the platform default policy (transactional categories on everywhere; marketing off for email/push; account/system keep push quiet). The in-app copy defaults to mirror push but is mutable independently.
GET /store/notifications/preferences — The matrix
Response 200 — array of { category, channels: { in_app: boolean; email: boolean; push: boolean } }, one row per category, merged over defaults.
PUT /store/notifications/preferences — Update overrides
Body: { entries: { category, channel, enabled }[] } (1..50 entries). Upserts the given cells; returns the full recomputed matrix.
Marketing email unsubscribe (public)
One-click opt-out for marketing/broadcast emails. Transactional (order/auth) email is exempt and never consults the suppression list. The unsubscribe link is injected into every campaign/broadcast email (as a footer link and the {{unsubscribe_url}} template variable).
Source:
api-modules/notifications/src/controllers/store-unsubscribe.controller.ts.
- No session/auth guard — the signed token in the link is the credential. It is an opaque
base64url(email).HMAC-SHA256(email)value (server secret:UNSUBSCRIBE_SECRET, falling back toBETTER_AUTH_SECRET). The endpoint verifies the signature in constant time; a valid token records a suppression row (idempotent upsert on the lower-cased email) inemail_suppression. The broadcast worker then skips that address for future marketing email. - Both verbs behave identically;
GETsupports one-click unsubscribe from the mail client,POSTmirrors it for form submissions.
GET /store/notifications/unsubscribe?token=<token> — Unsubscribe
Query: token (required, the signed token from the email link).
Response 200 — { "unsubscribed": true }. 400 (BAD_REQUEST) when the token is missing, malformed, or its signature doesn't verify.
POST /store/notifications/unsubscribe?token=<token> — Unsubscribe
Same query, response, and errors as the GET form.
Related modules
notifications-push-fcm— the FCM transport that delivers the push whose content the in-app feed mirrors.auth— owns the session whoseuser.idkeys the device, feed, and preference rows.
Invoice Module — Storefront
HTTP surface for a customer to check, generate, stream, and download the GST tax invoice(s) for their own order — async generation with SSE readiness notification.
Order Module — Storefront
HTTP surface for the customer-side order lifecycle — payment provider discovery, place-order, list/detail, customer-initiated cancel, and the customer-side return flow…