# Real-time Notifications: Frontend Integration Guide

**Audience:** storefront (learner), admin dashboard, and trainer dashboard frontend teams, plus whoever operates the API server (§9)
**API base (stage):** `https://bateel-back.elkady.aait-d.com/api/v1`
**Source of truth:** the persisted notification list (REST). SSE only says "something new arrived, refresh."
**Updating an existing app?** Start with [notifications-frontend-update.md](./notifications-frontend-update.md): a step-by-step migration with ready-to-use code.

---

## What changed (2026-09-27): read this if you built against the previous version

The stream used to run inside PHP-FPM. Every open tab held one server worker, so streams were capped at 3 concurrent connections (`503` beyond that) and forced to reconnect every few seconds, which slowed down the whole API. Streams are now served by a dedicated gateway process that holds hundreds of idle connections without touching PHP-FPM.

| Before | Now |
|---|---|
| `POST /notifications/stream-ticket` → `GET /notifications/stream?ticket=…&last_id=…` | `GET /{admin\|user\|trainer}/notifications/stream` with `Authorization: Bearer <JWT>` (§2). Tickets remain only for clients that can't send headers (§6). |
| Stream closed every ~8–25 s; `503` at capacity | One long-lived stream per tab (recycled about hourly, or when the JWT expires) |
| `data:` = `{message, payload:{title, meta}}` | `data:` = `{id, type, entity, title, body, data, readAt, createdAt}` (§3) |
| `:heartbeat` comment | `event: heartbeat` every 30 s |
| No database id in the event | `id` = the persisted notification id, so you can de-duplicate against the REST list |

The REST list (`GET /notifications`) is unchanged. New REST helpers: `GET /notifications/unread-count`, `PATCH /notifications/{id}/read`, and `PATCH /notifications/read-all`.

---

## 1. How it works

```
Business action ─▶ notifications table (REST list, unread count)   ◀── source of truth
              └──▶ Redis feed ─▶ SSE gateway ─▶ open streams of that user only
```

1. When a business event happens (enrollment, order, admin message, …), a notification row is stored per recipient and also pushed to the real-time feed.
2. Each open stream receives only its own user's notifications, usually within ~250 ms.
3. SSE is a nudge. After each `notification` event, and every time the stream (re)opens, refetch the REST list and unread count. Anything that arrived while you were offline is recovered this way.

---

## 2. HTTP contract

REST calls use the normal JSON envelope `{status, message, data}`. The stream body is raw SSE text.

| Purpose | Method and path (after `/api/v1`) | Auth | Response `data` |
|---|---|---|---|
| **Stream (learner storefront)** | `GET /user/notifications/stream` | Bearer, account type `user` | `text/event-stream` |
| **Stream (admin dashboard)** | `GET /admin/notifications/stream` | Bearer, `super_admin`/`supervisor` | `text/event-stream` |
| **Stream (trainer dashboard)** | `GET /trainer/notifications/stream` | Bearer, account type `trainer` | `text/event-stream` |
| List | `GET /notifications?read_status=read\|unread&page=N` | Bearer | `{unread_count, notifications: {<role>_notifications: {data, links, meta}}}` |
| Unread count | `GET /notifications/unread-count` | Bearer | `{count}` |
| Mark one read | `PATCH /notifications/{id}/read` | Bearer | `null` (`404` if not yours) |
| Mark all read | `PATCH /notifications/read-all` | Bearer | `{count}` |
| Delete one / clear all | `DELETE /notifications/{id}` · `DELETE /notifications/clear-all` | Bearer | `null` |

> **Mapping from the NestJS guide:** the storefront's `/client/...` paths are `/user/...` here, and the REST endpoints are shared (`/notifications/...`, no `/client` or `/admin` prefix). Only the stream is per-surface.

Legacy aliases still work: `GET /notifications/{id}` marks one read, and `POST /notifications/mark-all-read`.

### 2.1 Errors before the stream starts

These are ordinary JSON responses, so check `response.ok` before reading the body:

| Status | Meaning | Client action |
|---|---|---|
| `401` | Missing, invalid, expired, or logged-out token | Refresh the token or sign out. **Do not retry in a loop.** |
| `403` | Account deactivated, or wrong surface (e.g. a learner token on `/admin/...`) | Stop. Don't retry. |
| `429` | More than 10 open streams for this account (too many tabs) | Retry after `Retry-After` seconds |
| `503` | This gateway process is full | Retry with backoff |

---

## 3. Stream frames

Each frame ends with a blank line:

```text
: connected

retry: 5000

id: 1758619200000-0
event: notification
data: {"id":"9b1d6c0e-4f7a-4d3e-9a51-2f0c9d1e7a10","type":"course.enrollment_confirmed","entity":{"type":"course","id":"course-public-uuid"},"title":"Enrollment confirmed","body":"Your course enrollment and payment were successful.","data":{"course_id":12,"user_id":44},"readAt":null,"createdAt":"2026-09-23T10:00:00.000000Z"}

event: heartbeat
data: "2026-09-23T10:00:30Z"

```

```ts
type StreamNotification = {
  id: string;                                   // persisted notification UUID — same as the REST item id
  type: string;                                 // stable event name, see §8 (fallback "general")
  entity: { type: string; id: string } | null;  // what to open on click (§7); ids are strings
  title: string | null;                         // already in the recipient's language
  body: string | null;
  data: Record<string, unknown>;                // extra context; never route on it
  readAt: string | null;                        // always null for new events
  createdAt: string;                            // ISO-8601 UTC
};
```

- Listen for the **named** event `notification`. Ignore `heartbeat` and comment lines (`: …`).
- `id:` is a feed cursor. Browsers send it back as `Last-Event-ID` on auto-reconnect, and the gateway then replays that user's notifications missed during the gap. Fetch clients can pass it as `?last_id=`. You still refetch REST on open, so replay is a bonus, not a requirement.
- Titles and bodies are rendered in the language stored on the user's profile, not in the language of the stream request.
- The server may end the stream cleanly at any time: about hourly, when the JWT expires, or on deploy. Treat EOF exactly like a network error and reconnect.

---

## 4. Storefront (Next.js): proxy + `EventSource`

Browser `EventSource` can't set `Authorization`, so the storefront keeps its same-origin proxy route, which attaches the server-session token:

```ts
// app/api/client/notifications/stream/route.ts
export async function GET(request: Request) {
  const token = await getAccessTokenFromServerSession() // this app's auth helper (withAuthHeaders)
  if (!token) return new Response('Unauthorized', { status: 401 })

  const headers: Record<string, string> = {
    Accept: 'text/event-stream',
    Authorization: `Bearer ${token}`,
  }
  const lastEventId = request.headers.get('last-event-id')
  if (lastEventId) headers['Last-Event-ID'] = lastEventId // enables gap replay (optional)

  const upstream = await fetch(`${process.env.API_BASE_URL}/api/v1/user/notifications/stream`, {
    headers,
    cache: 'no-store',
    signal: request.signal, // closes the upstream stream when the browser disconnects
  })

  if (!upstream.ok || !upstream.body) {
    return new Response(await upstream.text(), { status: upstream.status })
  }

  return new Response(upstream.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'X-Accel-Buffering': 'no',
    },
  })
}
```

If `API_BASE_URL` already ends in `/api/v1`, don't append it twice. The hook:

```ts
useEffect(() => {
  if (!enabled) return
  const source = new EventSource('/api/client/notifications/stream')

  const refresh = () => {
    void queryClient.invalidateQueries({ queryKey: queryKeys.notifications() })
    void queryClient.invalidateQueries({ queryKey: queryKeys.notificationUnreadCount() })
  }

  source.onopen = refresh // recover anything created before/while (re)connecting
  source.addEventListener('notification', (event) => {
    refresh()
    const notification = safeParse((event as MessageEvent).data) as StreamNotification | null
    if (!notification || shownToastIds.has(notification.id)) return
    shownToastIds.add(notification.id)
    // existing contextual toast rules (e.g. ticket replies) go here
  })

  // No `onerror = () => source.close()`: EventSource retries by itself after `retry: 5000`.
  // Close only on unmount / sign-out. If the proxy keeps returning 401, sign out.
  return () => source.close()
}, [enabled, queryClient])
```

---

## 5. Admin and trainer dashboards: `fetch` stream

The dashboards call the API directly, because `fetch` can send the bearer header. Use `${VITE_BASE_URL}/admin/notifications/stream`, or `/trainer/notifications/stream` for the trainer dashboard.

```ts
export function startNotificationStream(opts: {
  url: string                       // .../api/v1/admin/notifications/stream
  token: () => string | null
  onOpen: () => void                // invalidate list + unread count
  onNotification: (n: StreamNotification) => void
  onUnauthorized: () => void        // refresh token or sign out
}): () => void {
  let controller: AbortController | null = null
  let retryTimer: ReturnType<typeof setTimeout> | null = null
  let stopped = false
  let lastEventId: string | null = null
  let attempt = 0

  const scheduleRetry = () => {
    if (stopped) return
    const base = Math.min(5_000 * 2 ** attempt++, 60_000)
    retryTimer = setTimeout(connect, base * (0.8 + Math.random() * 0.4)) // jitter
  }

  const connect = async () => {
    const token = opts.token()
    if (stopped || !token) return
    controller = new AbortController()
    try {
      const url = new URL(opts.url)
      if (lastEventId) url.searchParams.set('last_id', lastEventId)
      const response = await fetch(url, {
        headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}`, 'Cache-Control': 'no-cache' },
        signal: controller.signal,
      })
      if (response.status === 401 || response.status === 403) return opts.onUnauthorized()
      if (!response.ok || !response.body) throw new Error(`SSE HTTP ${response.status}`)

      attempt = 0
      opts.onOpen()

      const reader = response.body.getReader()
      const decoder = new TextDecoder()
      let buffer = ''
      while (true) {
        const { done, value } = await reader.read()
        if (done) break                                 // clean EOF → reconnect below
        buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
        if (buffer.length > 1_000_000) buffer = ''      // defensive bound
        let end: number
        while ((end = buffer.indexOf('\n\n')) >= 0) {
          const frame = buffer.slice(0, end)
          buffer = buffer.slice(end + 2)
          let event = 'message', id: string | null = null
          const data: string[] = []
          for (const line of frame.split('\n')) {
            if (line.startsWith(':')) continue
            const [field, ...rest] = line.split(':')
            const value = rest.join(':').replace(/^ /, '')
            if (field === 'event') event = value
            else if (field === 'data') data.push(value)
            else if (field === 'id') id = value
          }
          if (id) lastEventId = id
          if (event !== 'notification') continue          // skip heartbeat
          try { opts.onNotification(JSON.parse(data.join('\n'))) } catch { /* ignore malformed */ }
        }
      }
    } catch (error) {
      if (stopped) return
    }
    scheduleRetry()
  }

  void connect()
  return () => {                                          // call on unmount / logout
    stopped = true
    controller?.abort()
    if (retryTimer) clearTimeout(retryTimer)
  }
}
```

In `onNotification`, invalidate `queryKeys.notifications.all`, then show the toast (de-duplicated by `notification.id`). Run one stream per signed-in tab, in a single top-level provider rather than per page, and make sure the cleanup runs, including under React StrictMode's double effects.

---

## 6. Clients that can't send headers (native `EventSource`, no proxy)

1. `POST /notifications/stream-ticket` with the bearer token → `{ticket, expires_in: 60}`.
2. `new EventSource(`${API}/notifications/stream?ticket=${ticket}`)`. The frames are identical to §3.
3. The ticket stays valid for 60 s, so the browser's built-in retry works within that window. After an `error` with `readyState === CLOSED`, fetch a new ticket before reopening. Don't put the JWT itself in the URL.

---

## 7. Click routing

Route from `entity` (SSE event) or `additional_data.resource` (persisted REST item). The exact screen paths belong to each frontend application; use this recommended mapping:

| Resource type | Suggested destination |
|---|---|
| `course` | Course details / trainer course workspace / admin course review, based on current role and event |
| `program` | Program details / trainer program workspace / admin program review |
| `class_session` | The session inside its owner course or program |
| `consultation_booking` | My consultation / trainer consultation / admin booking detail |
| `order` | My orders detail / admin book-order detail |
| `book` | Book details or user library |
| `review` | Related course, program, or consultation review section |
| `certificate` | My certificates / certificate details |
| `payment` | Payment history or a generic support/payment screen |

If an event is unknown, show it in the notification centre and do not fail navigation. This makes adding backend events safe for older clients.

---

## 8. Event catalogue

| Event | Recipients | When it occurs |
|---|---|---|
| `course.enrollment_confirmed` | Learner, admins | Course checkout/payment succeeds |
| `program.enrollment_confirmed` | Learner, admins | Program checkout/payment succeeds |
| `consultation.booking_confirmed` | Learner, admins | Consultation booking/payment succeeds |
| `consultation.booking_created` | Assigned trainer | Learner books the trainer's consultation |
| `book_order.created` | Learner, admins | Physical book checkout succeeds |
| `book.purchase_confirmed` | Learner, admins | Digital book/research purchase succeeds |
| `course.payment_failed`, `consultation_booking.payment_failed`, `order.payment_failed`, `payment.payment_failed` | Buyer, admins | A payment record is created or changed to `failed` |
| `course.session_scheduled`, `program.session_scheduled` | Enrolled learners, trainer, admins | A dated/time-defined session is scheduled |
| `course.assigned`, `program.assigned` | Assigned trainer | Admin assigns/reassigns content |
| `course.review_submitted` | Admins | Trainer submits a course for review |
| `program.review_submitted` | Admins | Trainer submits a program for review |
| `course.approved`, `program.approved` | Assigned trainer | Admin approves content |
| `course.revision_requested`, `program.revision_requested` | Assigned trainer | Admin asks for revision |
| `review.created` | Related trainer, admins | Learner reviews a course, program, or consultation |
| `consultation.meeting_link_available` | Learner | Admin or trainer provides a meeting link |
| `course.certificate_issued`, `program.certificate_issued` | Learner, admins | A certificate is issued |
| `book_order.delivered` | Buyer, admins | Admin marks a physical order delivered |
| `admin.message_sent` | Selected recipients | Admin dashboard sends a broadcast or targeted message |

Payment failures are prepared for gateway integration. Current checkout flows record an immediate successful payment; a real gateway webhook must update `payments.status` to `failed` for a failure event to be emitted.

---

### 8.1 Admin dashboard: sending a notification

```http
POST /api/v1/admin/notifications/send
Authorization: Bearer <ADMIN_JWT>
Content-Type: application/json
Accept: application/json
```

Broadcast to every active user:

```json
{
  "title": "Planned maintenance",
  "body": "The platform will be unavailable from 01:00 to 01:15.",
  "target": "broadcast"
}
```

Send to specific internal user IDs:

```json
{
  "title": "Action required",
  "body": "Please update your profile information.",
  "target": "targeted",
  "user_ids": [12, 44, 88]
}
```

The server persists the message and sends it through SSE with `type: "admin.message_sent"` and `entity: null`. The frontend should use the same generic SSE/list handling; there is no separate dashboard-message protocol.

---

## 9. Server setup (backend / ops)

The gateway is a separate long-lived process. **If it's missing, nothing breaks:** a stream request that reaches Laravel instead gets an empty stream with `retry: 30000`, and clients fall back to refetching REST every 30 s. No worker is held, but there's no real-time delivery either.

**Requirements:** Redis plus the phpredis extension (the feed is the Redis Stream `notifications:sse`), and the `notifications` queue worker running, since notifications are queued.

### 9.1 Run the gateway (pm2)

`ecosystem.yml` already declares it:

```bash
pm2 start ecosystem.yml   # starts only the apps that aren't already running
pm2 save
```

Don't use `--only 'Bateel Notification Stream'`: pm2 splits that value on spaces, so it matches nothing. Don't use `pm2 start artisan …` either: pm2 matches it to the queue workers (same `artisan` script) and replaces them.

It listens on `SSE_HOST:SSE_PORT` (default `127.0.0.1:8090`). Configuration lives in `config/sse.php` / `.env` (`SSE_*`).

### 9.2 Route the stream paths to it

The gateway must receive these four paths. Everything else keeps going to PHP-FPM:

`/api/v1/admin/notifications/stream` · `/api/v1/user/notifications/stream` · `/api/v1/trainer/notifications/stream` · `/api/v1/notifications/stream`

**Apache / cPanel (stage):** nothing to configure. The rule ships in `public/.htaccess`:

```apache
<IfModule mod_proxy_http.c>
    RewriteRule ^api/v1/((admin|user|trainer)/)?notifications/stream/?$ http://127.0.0.1:8090/api/v1/$1notifications/stream [P,L,E=no-gzip:1]
</IfModule>
```

It only works when `mod_proxy` and `mod_proxy_http` are loaded (`httpd -M | grep proxy`). If they aren't, the request falls through to Laravel's fallback stream (§11, first row). Enable them in WHM → EasyApache 4 (root). Apache's idle `Timeout` only has to exceed the 30 s heartbeat.

**nginx:**

```nginx
location ~ ^/api/v1/((admin|user|trainer)/)?notifications/stream$ {
    proxy_pass http://127.0.0.1:8090;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 3700s;   # > SSE_MAX_LIFETIME_SECONDS
}
```

The gateway answers CORS itself, using the same `config/cors.php` rules as the API. Keep any CDN or load-balancer idle timeout above 30 s, the heartbeat interval.

### 9.3 Capacity

- One gateway process serves up to `SSE_MAX_CONNECTIONS` (900) concurrent streams. PHP's `select()` can't watch more than 1024 sockets, so scale out by raising `instances` in `ecosystem.yml`. All instances share the port via `SO_REUSEPORT`.
- Each process polls Redis about 4 times per second in total, no matter how many users are connected. PHP-FPM usage for streams is zero.
- The process recycles itself above `SSE_MAX_MEMORY_MB` (256); pm2 restarts it and clients reconnect.

### 9.4 After every deploy

The gateway is long-lived and does not pick up new code on its own:

```bash
php artisan optimize:clear
pm2 restart 'Bateel Main Queue' 'Bateel Notifications Queue' 'Bateel Notification Stream'
```

---

## 10. Verify end to end

```bash
# 1. Stream opens immediately (': connected' + 'retry: 5000'), heartbeat after ~30 s
curl -i -N -H 'Accept: text/event-stream' -H "Authorization: Bearer $USER_TOKEN" \
  https://bateel-back.elkady.aait-d.com/api/v1/user/notifications/stream

# 2. Wrong surface → 403, no token → 401 (JSON envelope)
curl -i -H "Authorization: Bearer $USER_TOKEN" https://…/api/v1/admin/notifications/stream
```

3. With stream (1) open, send yourself a message from the admin dashboard (`POST /admin/notifications/send`, `target: targeted`). One `event: notification` frame should arrive, and `GET /notifications/unread-count` should reflect it.
4. Open a second user's stream. It must not receive the first user's notification.
5. In the apps: kill the network, create a notification while offline, then reconnect. The REST refetch on open should show it, with no duplicate toast.
6. Log out: the stream closes, and signing in as someone else never shows the previous user's events.

If `curl -N` prints nothing until the request ends, something between the client and the gateway is buffering (see §11).

---

## 11. Troubleshooting

| Symptom | Check |
|---|---|
| Stream returns `retry: 30000` and closes at once, with the comment `live stream gateway unavailable` | The proxy isn't routing the path to the gateway (§9.2), so the request reached Laravel. |
| `502`/`503` from the proxy | The gateway isn't running: `pm2 status`, `pm2 logs 'Bateel Notification Stream'`. |
| Stream opens but no notifications arrive | Redis or phpredis missing on the gateway or queue host (`laravel.log`: "SSE gateway cannot read the notification feed" / "SSE notification publish failed"), or the `notifications` queue worker is down. |
| Output only appears when the stream ends | Proxy or CDN buffering, or gzip on `text/event-stream` (§9.2). |
| Dashboard gets a CORS error | The origin is missing from `config/cors.php` (`allowed_origins` / `allowed_origins_patterns`). |
| `429` on the stream | One account has more than 10 tabs or streams open, or a client is leaking connections (no cleanup on unmount). |
| Constant reconnects | A client closes on every `error`, runs several loops, or ignores `401`. See §4 and §5. |

---

## 12. Contract rules

- The backend owns event names, recipients, title and body text, and `entity` references. The frontend owns presentation, sounds, routing, and retry UI.
- Never parse `title`/`body` to decide behaviour, and never route on `data`.
- Never put a JWT in a URL. Only the 60-second ticket (§6) may go there.
- SSE delivery is best-effort. Always refetch REST on (re)connect.
- Keep unknown `type` values visible and safe to click, so new backend events never break older clients.
