# 16 — Notifications (Admin Inbox)

## Overview

The admin-facing notifications inbox. Two surfaces:

1. **Header badge** — polled on every page load via `getNotificationCount()`; counts unread admin-targeted notifications.
2. **Notifications page** (`/notifications`) — last 100 notifications across all modules, with event-name resolution for those carrying `event_id` in their payload.

**Status:** Partial

The schema is partially diverged between writers — some controllers write `payload`, the inbox reads `data_props`. See "Known Issues".

---

## User Stories

| ID | As a | I want to | So that |
|----|------|-----------|---------|
| NTF-01 | Any admin | See a notification badge in the header with unread count | Catch important events |
| NTF-02 | Any admin | Open `/notifications` to see the last 100 entries | Audit the stream |
| NTF-03 | Any admin | For event-related notifications, see the human-readable event name | Context |

---

## Screens & Flows

```
Every page load
   │
   ▼
┌────────────────────────────────────────────┐
│ comman.js → GET /notificationCount         │
│ NotificationController::getNotificationCount│
│  → COUNT(notifications WHERE type='admin'  │
│         AND read_user_ids='')              │
│  → 5 latest notifications for the dropdown │
└──────────────────┬─────────────────────────┘
                   │ badge updated
                   ▼ click "See All"
┌────────────────────────────────────────────┐
│ /notifications  notifications/list.blade   │
│ NotificationController::index()            │
│  → Notification::orderBy(created_at desc)  │
│    .limit(100).get()                       │
│  → for each: if data_props.event_id        │
│      then look up EventModel::where(uuid)  │
│      and inject event_name into data       │
└────────────────────────────────────────────┘
```

### Routes & Actions

| Route | Method | Handler | Description |
|-------|--------|---------|-------------|
| `/notifications` | GET | `NotificationController::index()` | Last 100 notifications |
| `/notificationCount` | GET | `NotificationController::getNotificationCount()` | Badge poll endpoint |

Both are whitelisted as `'all'` in `permissionGroup()` and additionally in `ajaxRequest()`.

---

## Data Model

### `notifications`

```php
// App\Models\Notification (skeleton)
{
  id:                int,
  uuid:              string,
  module:            string,                  // e.g. 'rm-event'
  message:           string,
  status:            int,
  image:             string,
  type:              'admin'|'client'|'vendor'|...,    // used by badge filter
  read_user_ids:     string,                  // CSV of user ids that have read; '' = unread
  created_by:        int,
  created_type:      'admin'|'client'|'vendor'|...,
  country_id:        int|null,
  created_for:       int,
  created_for_type:  'admin'|'client'|'vendor'|...,
  is_broadcast:      0|1,
  is_seen:           0|1,
  notification_type: 'inapp'|...,
  data_props:        json string,             // ← inbox reads this
  payload:           json string,             // ← Feature 09 writes this
  created_at, updated_at,
}
```

> The `data_props` vs `payload` divergence is **the biggest schema risk in VisoAdmin** — see Known Issues.

---

## Validations & Business Rules

| Rule | Detail |
|------|--------|
| Badge counts only `type='admin'` rows | And only those with `read_user_ids = ''` (no one has read) |
| `data_props` is `json_decode`d | Failed decode → `$data` is null → no event lookup |
| Event lookup is by `uuid` | If event_id is integer (not uuid), lookup returns null silently |
| Inbox renders raw `Notification` model | Blade decides which columns to surface |
| Wrapped in try/catch | The badge endpoint won't 500 on a missing column; logs warning and returns `{new_count: 0}` |
| Title is truncated to 15 chars | `substr($parseData->title, 0, 15) . '...'` |
| `time_diff` is the helper | "5 minutes ago", "2 hours ago", etc. |

---

## API Endpoints

| Method | Path | Auth | Request | Response | Consumer |
|--------|------|------|---------|----------|----------|
| GET | `/notifications` | session | — | HTML | Browser |
| GET | `/notificationCount` | session | — | `{ new_count, notice: [{title, message, time_duration, link}, ...], see_all_link }` | Polled by every page |

---

## Upstream Impact

- **`notifications` table** — written by VisoAdmin (Feature 09 RM Create Event), OotboAPI (quote submitted, lead matched, etc.), Viso-Chat (chat message).
- **`events` table** — joined for event_name resolution.

---

## Downstream Impact

- **Badge UI on every page** — driven by `/notificationCount`.
- **Operational awareness** — the inbox is the only audit-style stream view in VisoAdmin.

---

## Impact of Changes

| If you change... | Risk to... | Level | Type |
|-----------------|------------|-------|------|
| Renaming `notifications.read_user_ids` | Badge count breaks — caught by try/catch → silently returns 0 | High | Data |
| Renaming `notifications.type` | Badge filter `type='admin'` returns empty | High | Data |
| Changing `data_props` → `payload` (or vice versa) | Inbox displays empty title/message OR Feature 09 writes don't get displayed | Critical | Data |
| Events stored without UUID | Event-name lookup fails silently | Medium | Data |
| Increasing limit from 100 | Slower inbox load |  Medium | UI |
| Removing the try/catch on count endpoint | Every page load may 500 if schema drifts | High | Service |

---

## Known Issues

- **`data_props` vs `payload` mismatch**:
  - `NotificationController::index()` reads `$notification->data_props`.
  - `NotificationController::getNotificationCount()` reads `$row->data_props`.
  - `RmController::storeEvent()` writes `$notification->payload`.
  - **Either the schema has both columns, or one of these is wrong.** Verify against the actual `notifications` table; whichever doesn't exist needs a code-side fix or migration. **Critical.**
- **Truncate to 15 chars + ellipsis** for the badge titles — long titles always show as `"My really long..."`. Aggressive.
- **`read_user_ids` is stored as string, not as a relation table** — checking unread is `read_user_ids = ''`. Mass-marking-as-read would need a CSV append. There's no "mark as read" endpoint in this code — the badge would never decrement unless an external process updates the column.
- **Page load polling** — the badge fires `/notificationCount` on every navigation. Hot path. Adding indexes on `(type, read_user_ids)` may be needed at scale.
- **`time_diff` helper** uses `DateTime` directly and may produce timezone-confusing values if server tz drifts.
