---
title: Notification
tags: [data-model, mongoose]
aliases: [User Notification, INotification]
---

# Notification

> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))

Per-user notification entry. Each row binds to one `userId` (stored as a string rather than ObjectId), carries a typed severity (`info` / `success` / `warning` / `error`) and a domain category, optionally references another entity via `relatedId`, and supports an `actionUrl` for deep-linking. Old notifications are auto-purged by a 90-day TTL index (`createdAt` with `expireAfterSeconds = 7,776,000`).

> [!note] Source
> `backend/src/models/Notification.ts:18` — schema definition
> `backend/src/models/Notification.ts:79` — model export

> [!warning] String userId
> `userId` is a String, not an ObjectId, and is not declared with `ref`. Consumers must cast to `ObjectId` if they want to `populate()` it as a [[User]].

> [!warning] `category` enum vs reality
> The schema enum is `purchase_request` / `offer` / `payment` / `delivery` / `system`, but in practice:
> - `notificationController.createNotification` defaults the category to **`'general'`** (`category = 'general'`) when the caller omits it. `'general'` is **not** in the schema enum — Mongoose enum validation will reject it on a strict save, so callers must supply a valid value or the write fails. Treat `'general'` as a value you may encounter in payloads even though it is not an enum member.
> - The frontend socket hook `use-notifications.ts` hardcodes `category: 'system'` for every realtime-injected notification, so most client-side notifications surface as `'system'` regardless of their true domain.
> - `NotificationService.notifyRequestStatusChanged` always writes `category: 'system'` for purchase-request status changes.

## Schema

| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `userId` | String | yes | — | — | yes (single + compound) | Owner of the notification. |
| `title` | String | yes | — | maxlength 200 | — | Headline. |
| `message` | String | yes | — | maxlength 1000 | — | Body. |
| `type` | String | yes | `info` | enum: `info` / `success` / `warning` / `error` | — | Severity. |
| `category` | String | yes | — | enum: `purchase_request` / `offer` / `payment` / `delivery` / `system` | yes (compound) | Domain bucket. ⚠️ `notificationController` defaults to `'general'` (not in the enum) and the realtime socket hook + `notifyRequestStatusChanged` hardcode `'system'`. See warning above. |
| `relatedId` | String | no | — | — | yes | Id of the related entity (e.g. [[PurchaseRequest]]). |
| `metadata` | Mixed | no | — | — | — | Arbitrary payload. |
| `actionUrl` | String | no | — | maxlength 500 | — | Deep link. |
| `isRead` | Boolean | no | `false` | — | yes (compound) | Read flag. |
| `readAt` | Date | no | — | — | — | When read. |
| `createdAt` | Date | auto | — | — | yes (compound + TTL) | Mongoose timestamp. Auto-deleted after 90 days by TTL index. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |

The collection name is overridden to `notifications` via `collection: 'notifications'`.

## Virtuals

None defined.

## Indexes

Defined at `backend/src/models/Notification.ts:71-77`:

- `{ userId: 1, createdAt: -1 }` — user feed.
- `{ userId: 1, isRead: 1 }` — unread badge.
- `{ userId: 1, category: 1 }` — category filter.
- `{ relatedId: 1 }` — lookup by linked entity.
- `{ createdAt: 1 }` with `expireAfterSeconds: 60 * 60 * 24 * 90` (7,776,000 s) — MongoDB TTL index; the database hard-deletes documents automatically after 90 days.

Plus the implicit index from `userId` having `index: true` at the field level.

## Pre/Post Hooks

None declared.

## Instance Methods

None defined.

## Static Methods

None defined.

## Status-change notification coverage

`NotificationService.notifyRequestStatusChanged` maps a [[PurchaseRequest]] status to a human label via an internal `statusMessages` table. That table covers `pending`, `active`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, and `cancelled`.

> [!warning] Missing status templates
> The `pending_payment` and `seller_paid` [[PurchaseRequest]] statuses have **no entry** in the `statusMessages` table and no dedicated notification template. Transitions into these states do not produce a meaningful status-change notification (the label falls back to the raw status string, and several flows skip notification entirely). If you rely on notifications for `pending_payment` / `seller_paid`, they will not arrive as expected.

## Relationships

- **References**: [[User]] indirectly through `userId` (string); arbitrary entity via `relatedId`.
- **Referenced by**: none.

## State Transitions

```mermaid
stateDiagram-v2
    [*] --> unread
    unread --> read : user opens
    read --> [*] : TTL purge (90d)
    unread --> [*] : TTL purge (90d)
```

## Common Queries

```ts
// User feed
Notification.find({ userId }).sort({ createdAt: -1 }).limit(50);

// Unread badge count
Notification.countDocuments({ userId, isRead: false });

// Mark all read
Notification.updateMany(
  { userId, isRead: false },
  { $set: { isRead: true, readAt: new Date() } }
);

// All notifications about a request
Notification.find({ relatedId: purchaseRequestId.toString() });
```

Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Payment]].
