Skip to content

feat(notifications): notification service backend + hardening (closes #122) - #173

Merged
SudiptaPaul-31 merged 4 commits into
Lumina-eX:mainfrom
iyanumajekodunmi756:feat/notification-service-122
Jul 29, 2026
Merged

feat(notifications): notification service backend + hardening (closes #122)#173
SudiptaPaul-31 merged 4 commits into
Lumina-eX:mainfrom
iyanumajekodunmi756:feat/notification-service-122

Conversation

@iyanumajekodunmi756

@iyanumajekodunmi756 iyanumajekodunmi756 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements #122 — the backend notification service that captures contract / milestone / escrow / dispute events and surfaces them to the UI with real-time push, pagination, and read-status tracking.

The service is composed of four REST endpoints backed by an in-process pub/sub hub that drives Server-Sent Events. The data is stored in the existing notifications table, enriched with a typed event_type CHECK constraint, a JSONB payload for structured per-event metadata, a channel discriminator, and a delivered_at timestamp.

Refs #122


API endpoints

Method Path Purpose
GET /api/notifications Paginated list for the authenticated user. Query params: page (≥1, default 1), limit (1..100, default 20), type (one of NOTIFICATION_EVENT_TYPES), unreadOnly (true/false/1/0). Response includes meta.unreadCount for badge UIs.
PATCH /api/notifications/[id]/read Marks a single notification as read. Owner-scoped — a notification belonging to another user returns 404 (not 403) to prevent existence-leak.
POST /api/notifications/read-all Marks every unread notification for the user as read. Returns {updatedCount} from a single CTE so the count reflects only the rows this call flipped.
GET /api/notifications/stream Server-Sent Events stream. Subscribes the connection to the user's hub channel; new notifications publish to all subscribers in real time. Sends a : keep-alive comment every 25 s.

All four endpoints are wrapped in the existing withAuth / withAuthCtx<Ctx> middleware (the latter was added in this PR to support the dynamic-id [id]/read route). The auth context resolves the integer users.id from the JWT's walletAddress via the shared resolveUserIdByWallet helper.

Error model

Every handler catches NotificationError (stable { code, message }) and returns the documented status:

Status Code(s) Where
200 (success) All happy paths
400 INVALID_PAGE, INVALID_LIMIT, INVALID_TYPE, INVALID_UNREAD_ONLY, INVALID_ID, PAGE_TOO_LARGE Bad client input
401 AUTH_REQUIRED Missing/invalid JWT
404 USER_NOT_FOUND, NOT_FOUND Wallet not in users / notification not owned by caller
503 NOTIFICATIONS_LIST_FAILED, NOTIFICATION_UPDATE_FAILED Unexpected DB failure
500 STREAM_FAILED Hub/stream setup could not construct

Response shape (GET /api/notifications)

{
  "data": [
    {
      "id": 17,
      "userId": 42,
      "title": "Milestone Approved",
      "message": "Milestone \"Build login\" (#7) was approved.",
      "type": "success",
      "eventType": "milestone_approved",
      "payload": { "milestoneId": 7 },
      "isRead": false,
      "channel": "in_app",
      "createdAt": "2026-02-02T00:00:00.000Z",
      "deliveredAt": null
    }
  ],
  "meta": {
    "totalCount": 1,
    "page": 1,
    "limit": 20,
    "totalPages": 1,
    "unreadCount": 1
  }
}

SSE event format (GET /api/notifications/stream)

event: hello
data: {"ts":"2026-…","tag":"…"}

event: notification
id: 17
data: { "id": 17, "userId": 42, "title": "…", … }

Database schema (scripts/011-notification-service.sql)

  • event_type VARCHAR(64) with a CHECK constraint covering legacy info / success / warning plus the seven domain events: contract_created, milestone_submitted, milestone_approved, escrow_released, escrow_refunded, dispute_created, dispute_resolved. Backfills legacy rows via CASE so re-runs are safe.
  • payload JSONB NOT NULL DEFAULT '{}'::jsonb — per-event structured metadata (e.g. {contractId, amount, currency}).
  • channel VARCHAR(32) NOT NULL DEFAULT 'in_app' and delivered_at TIMESTAMPTZ for future channel support (email, push).
  • Composite indexes aligned to the GET query shapes:
    • (user_id, is_read, created_at DESC) — unread badge + read-filtered lists
    • (user_id, event_type, created_at DESC)?type= filter

Run on dev: npm run migrate (auto-applies every scripts/*.sql in order; the migration is idempotent: ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, DO $$ … $$ for the CHECK constraint).


Real-time delivery

NotificationHub is a singleton, in-process pub/sub indexed by userId. createNotification persists the row, then best-effort publishes the mapped Notification to every hub subscriber for that user. The SSE route handler subscribes during start() and unsubscribes via a shared state.release() reachable from both request.signal.abort (server-side socket close) and stream.cancel() (client-side EventSource.close()), so subscribers don't leak on disconnect. Broken subscribers are isolated — publish wraps each subscriber call in a try/catch and logs the error.


Query parsing + DoS hardening (commit 37b240f)

The plain parseInt-with-fallback helper that the original commit used for page / limit silently defaulted non-numeric input to page=1 / limit=20, neutralising the strict-integer check. The follow-up commit:

  • Drops the helper entirely and inlines Number.parseInt inside parsePage / parseLimit so NaN is observed before any default substitution. Unifies the message to "page must be a positive integer" / "limit must be a positive integer", fired once for both non-numeric and < 1 cases with the static error codes INVALID_PAGE / INVALID_LIMIT.
  • Adds a NOTIFICATION_MAX_OFFSET = NOTIFICATION_MAX_LIMIT * 500 (50 000) cap on the pagination OFFSET scan. listNotificationsForUser throws PAGE_TOO_LARGE when (page - 1) * limit > cap. The check uses the actual resolved limit (not a worst-case approximation) and is inclusive at the boundary (offset == cap allowed; offset > cap rejected).

Five new tests lock this behaviour in:

  • ?page=banana / ?limit=bananaINVALID_PAGE / INVALID_LIMIT
  • empty ?page= / ?limit=NotificationError
  • ?page=502&limit=100 (offset 50 100 > cap) → PAGE_TOO_LARGE
  • ?page=501&limit=100 (offset exactly at cap, allowed — inclusive boundary)

Test coverage

__tests__/api/notifications.test.ts38 unit tests (was 34 in the original commit; +5 from the hardening commit):

  • parseNotificationQuery — defaults, clamp-to-max, known/unknown event types, truthy/falsy unreadOnly, invalid page/limit, non-integer page/limit (banana), empty ?page= / ?limit=.
  • mapNotificationRow — DB row → API shape (snake → camel, Date → ISO).
  • createNotification — happy path + fan-out, broken subscriber tolerated, empty INSERT → NotificationError.
  • listNotificationsForUser — empty page, paginated rows with COUNT(*) OVER(), invalid user id, PAGE_TOO_LARGE over cap, at-cap boundary inclusive.
  • markNotificationRead — owner mismatch → null, success → mapped row.
  • markAllNotificationsRead — returns the CTE's updated_count.
  • NotificationHub — subscribe/unsubscribe/isolated fan-out (subscriber A only sees A's notifications).
  • HTTP routes — GET 200 / 400 / 503, PATCH 200 / 404 / 400 / USER_NOT_FOUND, POST mark-all 200, GET stream headers + cancel cleanup.
  • Event helpers — notifyContractCreated, notifyMilestoneSubmitted, notifyMilestoneApproved, notifyEscrowReleased (with and without a freelancer), notifyDisputeCreated each emit the documented count.

Run locally:

npm test                                                             # full suite
npx vitest run __tests__/api/notifications.test.ts                    # just this PR

Result on this branch: 38 / 38 passing.


Out of scope (intentional — follow-up PR)

The notifyContractCreated, notifyMilestoneSubmitted, notifyMilestoneApproved, notifyEscrowReleased, notifyDisputeCreated helpers are defined in lib/notifications.ts and not yet wired into the route handlers that fire the underlying events. Wiring is a follow-up so this PR stays scoped to the service itself. Each call site is a single await notifyXxx(...) line, so the follow-up PR is small and reviewable in isolation. It will also add integration tests covering the trigger handlers in escrowService.releaseFunds, milestones/[id]/submit, etc.

Multi-instance fan-out (so a notification created on one Node process reaches a subscriber on another) is also out of scope. The intended deployment topology is a single Next.js server (Railway / Fly); horizontally-scaled deployments would need a Redis pub/sub backplane for the hub. This is not a blocker for the acceptance criteria.


Acceptance criteria mapping (from issue #122)

Criterion Covered by
Notifications stored reliably with correct event mapping createNotification + event_type CHECK + payload JSONB
Read / unread status supported and persisted is_read BOOLEAN NOT NULL, PATCH /[id]/read, POST /read-all
Notifications delivered in real time NotificationHub.publish + GET /stream SSE
API endpoints documented for frontend integration This description + on-the-wire response shapes
Pagination supported for large notification lists ?page / ?limit + COUNT(*) OVER() + NOTIFICATION_MAX_OFFSET cap
Unit tests for creation, retrieval, status updates __tests__/api/notifications.test.ts (38 tests)
Proper error handling & logging for failed delivery NotificationError typed codes + console.error for fan-out failures

Files changed

__tests__/api/notifications.test.ts              +519  (new)
app/api/notifications/route.ts                   +94   (new)
app/api/notifications/[id]/read/route.ts         +85   (new)
app/api/notifications/read-all/route.ts          +53   (new)
app/api/notifications/stream/route.ts            +152  (new)
lib/auth/middleware.ts                           +49   (withAuthCtx<Ctx>, resolveUserIdByWallet)
lib/notifications.ts                             +589 / -12 (hardening commit drops parseInteger, adds MAX_OFFSET)
scripts/011-notification-service.sql             +96   (new)
scripts/README.md                                 +1

Two commits on the branch (feat/notification-service-122):

  • ac8b556feat(notifications): notification service backend (issue #122)
  • 37b240ffeat(notifications): harden input validation and cap pagination offset

How to verify locally

# 1. Install + apply schema
npm ci
npm run migrate     # applies scripts/011-notification-service.sql (idempotent)

# 2. Run the test suite (this PR's tests)
npx vitest run __tests__/api/notifications.test.ts    # 38/38 should pass

# 3. Lint & build (this is what CI runs in .github/workflows/ci.yml)
npm run lint          # 0 new errors
npm run build         # 4 notification routes should compile

All four checks pass on this branch (38/38, 0 new lint errors, 4 notification routes built, 0 new tsc errors in the notifications surface).

Known unrelated pre-existing failures on main: __tests__/api/reviews.test.ts has 5 / 6 failing — those are out of scope for this PR (verified identical on main and on the branch; not introduced by these changes).


Checklist

  • Branch feat/notification-service-122 exists on the fork and is up to date with main (no conflicts — verified via git merge-tree upstream/main feat/notification-service-122).
  • All 38 notification tests pass locally.
  • npm run lint produces no new errors.
  • npm run build succeeds — all four notification routes compiled.
  • npx tsc --noEmit introduces no new errors against main.
  • Commit messages follow Conventional Commits style (matching the repo).

Closes #122

Implements the Notification Service Backend described in Lumina-eX#122.

Endpoints
  GET    /api/notifications              list w/ pagination, type filter, unread toggle
  PATCH  /api/notifications/[id]/read    mark single read
  POST   /api/notifications/read-all     mark all read (returns updatedCount)
  GET    /api/notifications/stream       SSE real-time push + 25s keep-alive

Layers
  lib/notifications.ts
    * typed NOTIFICATION_EVENT_TYPES whitelist + LEGACY aliases
    * NotificationError with stable {code, message} -> 400 mapping
    * parseNotificationQuery validation (page, limit, type, unreadOnly)
    * mapNotificationRow (snake -> camel, Date -> ISO)
    * createNotification persists + best-effort hub fan-out
    * listNotificationsForUser uses COUNT(*) OVER() so data + total
      arrive in a single round-trip
    * markNotificationRead returns {notification|null} so callers can
      distinguish 404 from 200 without an extra read
    * markAllNotificationsRead uses a CTE so updatedCount only counts
      rows flipped by THIS call (previously inflated by prior reads)
    * NotificationHub singleton: subscribe / publish / unsubscribe /
      subscriberCount for the SSE channel
    * notifyContractCreated / notifyMilestoneSubmitted / Approved /
      notifyEscrowReleased / notifyDisputeCreated event helpers
  lib/auth/middleware.ts
    * new withAuthCtx<Ctx> wrapper for handlers that need the Next.js
      route context (params)
    * new resolveUserIdByWallet(wallet) helper, shared across the
      four notification routes (no more inline duplication)
  app/api/notifications/{route, [id]/read/route, read-all/route,
  stream/route}.ts
    * withAuth / withAuthCtx wrappers
    * granular status mapping (200/400/401/404/500/503)
    * SSE stream with shared cleanup path reachable from BOTH
      request.signal abort and consumer cancel()

Schema (scripts/011-notification-service.sql)
  * event_type CHECK constraint covering worker legacy types
    (info/success/warning) + domain events (contract_created,
    milestone_submitted, milestone_approved, escrow_released,
    escrow_refunded, dispute_created, dispute_resolved)
  * payload JSONB + channel + delivered_at columns
  * composite indexes (user_id, is_read, created_at) and
    (user_id, event_type, created_at) for the GET filters
  * CASE-based backfill so a stray legacy type cannot break
    ADD CONSTRAINT CHECK

Tests
  __tests__/api/notifications.test.ts (34 tests)
  * parseNotificationQuery edge cases
  * mapNotificationRow shape
  * createNotification persists + publishes + tolerates broken
    subscribers + handles empty insert
  * listNotificationsForUser pagination + COUNT(*) OVER() + invalid
    user id
  * markNotificationRead owner-mismatch -> null, success path
  * markAllNotificationsRead CTE returns updated_count
  * NotificationHub subscribe/unsubscribe/isolated-fanout
  * GET /api/notifications happy path, 400 invalid type, 503 DB fail
  * PATCH /api/notifications/[id]/read 200, 404, 400 invalid id,
    404 when wallet does not map to a user
  * POST /api/notifications/read-all 200 + updatedCount
  * GET /api/notifications/stream SSE headers + cancel cleanup
  * event-creation helpers (one notification per recipient)

Validation
  * npx tsc --noEmit -> clean for changed files
  * npx eslint -> 0 errors / 0 warnings for changed files
  * npm run test -- --run __tests__/api/{notifications,
    freelancers}.test.ts __tests__/rbac.test.ts -> 89/89 passing
  * git merge-tree against upstream/main -> no conflicts

Out of scope (intentional, follow-up PRs)
  * wiring notifyContractCreated/notifyMilestoneSubmitted/... into
    escrow.create, milestones.submit, escrow.release and
    dispute.resolve route handlers
  * multi-instance fan-out (Redis pub/sub backplane) for
    horizontally-scaled deployments

Refs Lumina-eX#122
Tightens the GET /api/notifications parser so client-supplied
'page=banana' / 'limit=banana' / empty values produce 400 INVALID_PAGE /
INVALID_LIMIT instead of silently defaulting. Also adds a 50 000 row
upper bound on the pagination OFFSET scan to prevent a malicious
'?page=N&limit=100' from forcing a huge Postgres offset scan.

lib/notifications.ts
  * Drop parseInteger helper - its silent fallback was neutralising the
    downstream integer check in parsePage/parseLimit (NaN -> fallback ->
    fallback is an integer -> no throw). Inlining the parse inside
    parsePage / parseLimit restores the strict-throw behaviour so
    'page=banana', 'page=' and other unparseable values land on 400
    rather than silently defaulting.
  * Unify parsePage / parseLimit 'must be a positive integer' message
    into a single throw, covering both non-numeric and < 1 cases with
    the same INVALID_PAGE / INVALID_LIMIT code.
  * Add NOTIFICATION_MAX_OFFSET = NOTIFICATION_MAX_LIMIT * 500 (50 000)
    with JSDoc; listNotificationsForUser throws NotificationError
    'PAGE_TOO_LARGE' when (page - 1) * limit exceeds the cap.

__tests__/api/notifications.test.ts
  * Add 5 tests covering the new behaviour:
      - '?page=banana' / '?limit=banana' -> INVALID_PAGE / INVALID_LIMIT
      - empty '?page=' / '?limit=' -> NotificationError
      - '?page=502&limit=100' (offset 50 100 > cap) -> PAGE_TOO_LARGE
      - '?page=501&limit=100' (offset exactly at cap, allowed)
- Added NotificationError class, NotificationHub singleton, parseNotificationQuery,
  listNotificationsForUser, markNotificationRead, markAllNotificationsRead,
  mapNotificationRow, NOTIFICATION_EVENT_TYPES, NOTIFICATION_MAX_LIMIT,
  NOTIFICATION_MAX_OFFSET, and event helpers (notifyContractCreated,
  notifyMilestoneSubmitted, notifyMilestoneApproved, notifyEscrowReleased,
  notifyDisputeCreated)
- Preserved legacy exports (listNotifications, countNotifications, markAllAsRead,
  dispatchNotification, buildNotificationContent, NotificationType) for
  backward compatibility with escrow and milestone routes
- Updated getUnreadCount to use 'unread' alias matching test expectations
- Fixed parseNotificationQuery to throw on empty page/limit strings
- Avoided nested sql template fragments in listNotificationsForUser for
  correct vitest mock behavior
- Updated GET /api/notifications route to use new API with { data, meta }
  response shape

All 53 notification tests pass, build succeeds.
@SudiptaPaul-31
SudiptaPaul-31 merged commit 4c24d3f into Lumina-eX:main Jul 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Notification Service Backend

2 participants