# Real-time Notifications: Frontend Update Guide

**For:** storefront (learner, Next.js), admin dashboard, and trainer dashboard developers
**Stage API base:** `https://bateel-back.elkady.aait-d.com/api/v1`
**Status:** live on stage since 2026-09-27
**Related:** [notifications-integration.md](./notifications-integration.md) is the full reference, including server setup.

---

## 0. TL;DR: what you have to change

The live notification stream was rebuilt so it no longer slows down the API. The contract changed, so every app needs an update.

| App | Required change |
|---|---|
| **Storefront (learner)** | Point the Next.js proxy route at `GET /user/notifications/stream` with the bearer token (§4). Remove the `onerror → close()`. Read the new event shape (§3). |
| **Admin dashboard** | Replace the ticket flow with a `fetch` stream to `GET /admin/notifications/stream` + `Authorization: Bearer` (§5). Read the new event shape (§3). |
| **Trainer dashboard** | Same as the admin dashboard, with `GET /trainer/notifications/stream` (§5). |
| **All apps** | Refetch the notification list and unread count every time the stream opens (§6). De-duplicate toasts by notification `id`. Optionally use the new REST helpers (§7). |

### Delete from your code

- The `POST /notifications/stream-ticket` call before each connection. It's no longer needed, but still works for §9.
- `?ticket=…&last_id=…` query strings on the stream URL.
- The custom 1s→15s ticket-refresh reconnect loop and the `503` "capacity" handling. The capacity limit no longer exists.
- Reading `message` / `payload.title` / `payload.meta` from stream events.
- Any `sessionStorage` bookkeeping of stream `lastEventId`s.

---

## 1. Before vs. after

| | Before | Now |
|---|---|---|
| Stream URL | `GET /notifications/stream?ticket=…&last_id=…` | `GET /user/notifications/stream`, `/admin/notifications/stream`, `/trainer/notifications/stream` |
| Auth | Short-lived ticket in the query string | `Authorization: Bearer <JWT>` header (the same token as every other API call) |
| Connection length | Closed by the server every ~8–25 s; `503` when more than 3 users were connected | Stays open. Recycled roughly every hour, or when the JWT expires. No global capacity limit. |
| Event `data:` | `{message, payload: {title, meta}}` | `{id, type, entity, title, body, data, readAt, createdAt}` |
| Keep-alive | `:heartbeat` comment | `event: heartbeat` every 30 s |
| Notification id in event | None | `id` = the same UUID as the REST list item |
| Missed events after reconnect | Client had to track `last_id` | Browser/`fetch` sends the last `id:` back and the server replays anything missed. REST refetch still covers everything. |

---

## 2. Endpoints

All paths are relative to `https://bateel-back.elkady.aait-d.com/api/v1`.

### 2.1 Stream: one per app surface

| App | Method + path | Who may open it |
|---|---|---|
| Storefront | `GET /user/notifications/stream` | Accounts with `user_type = user` |
| Admin dashboard | `GET /admin/notifications/stream` | `super_admin` or `supervisor` |
| Trainer dashboard | `GET /trainer/notifications/stream` | `user_type = trainer` |

Request headers:

```http
Accept: text/event-stream
Authorization: Bearer <JWT>
Cache-Control: no-cache
```

> **Coming from the NestJS guide?** Its `/client/notifications/stream` is `/user/notifications/stream` here.

### 2.2 REST: shared by all three apps (no `/user`, `/admin`, `/trainer` prefix)

| Purpose | Method + path | `data` in the response |
|---|---|---|
| List (paginated, 15 per page) | `GET /notifications?page=1&read_status=unread` | `{unread_count, notifications: {...}}` (§7.1) |
| Unread badge count **(new)** | `GET /notifications/unread-count` | `{"count": 3}` |
| Mark one as read **(new)** | `PATCH /notifications/{id}/read` | `null` |
| Mark all as read **(new)** | `PATCH /notifications/read-all` | `{"count": 2}` (how many were changed) |
| Delete one | `DELETE /notifications/{id}` | `null` |
| Clear all | `DELETE /notifications/clear-all` | `null` (this endpoint used to return 404 by mistake; it's fixed now) |
| Mark one read (legacy) | `GET /notifications/{id}` | `null` |
| Mark all read (legacy) | `POST /notifications/mark-all-read` | `{"count": 2}` |

Every REST response uses the standard envelope:

```json
{ "status": "success", "message": "…", "data": { … } }
```

Send `Accept-Language: ar` or `en` on REST calls; it controls the language of `message`.

> The admin dashboard's own notifications also come from `GET /notifications`. `GET /admin/notifications` is something else: the list of broadcasts the admin has *sent*.

---

## 3. What the stream sends

### 3.1 Raw wire format

Every frame ends with a blank line:

```text
: connected

retry: 5000

event: heartbeat
data: "2026-09-27T08:44:43Z"

id: 1790498685706-0
event: notification
data: {"id":"092d579d-402e-4aa2-970d-c507b9ef0ac0","type":"admin.message_sent","entity":null,"title":"Test title","body":"Test body","data":{},"readAt":null,"createdAt":"2026-09-27T08:44:45.706328Z"}

```

This is a real capture from stage.

| Frame | What to do |
|---|---|
| `: connected` (comment) | Nothing. It's sent immediately so proxies start forwarding the stream. |
| `retry: 5000` | Nothing. `EventSource` uses it as its reconnect delay; `fetch` clients should wait about 5 s too. |
| `event: heartbeat` | Ignore. It's sent every 30 s to keep the connection alive. |
| `event: notification` | Parse `data` as JSON (§3.2), refresh the list and badge, and show a toast. |
| `id:` line | Remember it. It's sent back on reconnect so missed events are replayed (§6.3). |

### 3.2 Notification payload

```ts
export type StreamNotification = {
  /** Persisted notification UUID. Same value as the REST item `id`. */
  id: string
  /** Stable machine event name, e.g. "course.enrollment_confirmed" (§10). "general" if unknown. */
  type: string
  /** What the notification is about; use it for click routing (§11). Null for admin messages. */
  entity: { type: string; id: string } | null
  /** Already translated into the recipient's profile language. */
  title: string | null
  body: string | null
  /** Extra context (ids, status, starts_at…). Never route or branch on this. */
  data: Record<string, unknown>
  /** Always null for a new event. */
  readAt: string | null
  /** ISO-8601 UTC, e.g. "2026-09-27T08:44:45.706328Z". */
  createdAt: string
}
```

Notes:

- All ids are **strings**, including `entity.id`. Don't convert them to numbers.
- `title`/`body` use the language saved on the **user's profile** (`locale`), not the `Accept-Language` of the stream request. To change it, the user updates their profile `locale`.
- `data` is always an object: `{}` when empty, never `[]`.

---

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

Browser `EventSource` can't send an `Authorization` header, so the browser connects to a same-origin Next.js route. That route attaches the user's token on the server and passes the stream through untouched.

### 4.1 Proxy route

```ts
// app/api/client/notifications/stream/route.ts
export const dynamic = 'force-dynamic'

export async function GET(request: Request) {
  const token = await getAccessTokenFromServerSession() // use the app's withAuthHeaders() helper
  if (!token) return new Response('Unauthorized', { status: 401 })

  const headers: Record<string, string> = {
    Accept: 'text/event-stream',
    Authorization: `Bearer ${token}`,
    'Cache-Control': 'no-cache',
  }

  // Forward EventSource's automatic Last-Event-ID so missed events are replayed.
  const lastEventId = request.headers.get('last-event-id')
  if (lastEventId) headers['Last-Event-ID'] = lastEventId

  const upstream = await fetch(`${apiUrl('/user/notifications/stream')}`, {
    headers,
    cache: 'no-store',
    signal: request.signal, // browser disconnects → upstream stream closes too
  })

  if (!upstream.ok || !upstream.body) {
    // 401/403 come back as JSON; pass the status through so the hook can react.
    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',
    },
  })
}
```

- `apiUrl()` must produce `https://bateel-back.elkady.aait-d.com/api/v1/user/notifications/stream`. Don't append `/api/v1` twice.
- Keep `signal: request.signal`, or closed tabs keep upstream connections open.
- Don't let Next.js cache or compress this route.

### 4.2 Hook

```ts
// hooks/api/use-notifications.ts
export function useNotificationStream(enabled: boolean) {
  const queryClient = useQueryClient()
  const shownToastIds = useRef(new Set<string>())

  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() })
    }

    // Fires on the first connect AND on every automatic reconnect: recovers
    // anything created while the tab was offline.
    source.onopen = refresh

    source.addEventListener('notification', (event) => {
      refresh()

      const notification = safeParse<StreamNotification>((event as MessageEvent).data)
      if (!notification || shownToastIds.current.has(notification.id)) return
      shownToastIds.current.add(notification.id)

      // Existing toast rules stay (e.g. ticket-reply toast only when not on that page).
      showNotificationToast(notification)
    })

    // IMPORTANT: no `source.onerror = () => source.close()`.
    // EventSource reconnects on its own after `retry: 5000`, including after the
    // server's hourly recycle. Close only on unmount / sign-out.
    return () => source.close()
  }, [enabled, queryClient])
}
```

**Handling `401` (session expired).** When the proxy returns `401`, `EventSource` stops permanently (`readyState === EventSource.CLOSED`). Handle it in `onerror`:

```ts
source.onerror = () => {
  if (source.readyState === EventSource.CLOSED) {
    // The proxy answered with a non-stream response (401/403): the session is
    // gone. Refresh the session or sign out; the effect re-runs when `enabled` changes.
    void handleSessionExpired()
  }
  // readyState CONNECTING = normal auto-retry in progress: do nothing.
}
```

---

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

The dashboards call the API directly, because `fetch` can send `Authorization`. Drop this module into both dashboards. Only the URL differs.

### 5.1 Stream client

```ts
// src/lib/notificationStream.ts
export type StreamOptions = {
  url: string                                  // `${VITE_BASE_URL}/admin/notifications/stream` or /trainer/...
  getToken: () => string | null
  onOpen: () => void                           // refresh list + unread count
  onNotification: (n: StreamNotification) => void
  onUnauthorized: () => void                   // 401/403: sign out / re-login
}

export function startNotificationStream(opts: StreamOptions): () => void {
  let controller: AbortController | null = null
  let retryTimer: ReturnType<typeof setTimeout> | null = null
  let stopped = false
  let lastEventId: string | null = null
  let failures = 0

  const scheduleReconnect = () => {
    if (stopped) return
    // 5s after a normal close; back off up to 60s while the server is unreachable; ±20% jitter.
    const base = Math.min(5_000 * 2 ** failures, 60_000)
    retryTimer = setTimeout(connect, base * (0.8 + Math.random() * 0.4))
  }

  const connect = async () => {
    retryTimer = null
    const token = opts.getToken()
    if (stopped || !token) return

    controller = new AbortController()
    try {
      const url = new URL(opts.url)
      if (lastEventId) url.searchParams.set('last_id', lastEventId) // replay missed events

      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) {
        opts.onUnauthorized()
        return                                         // never retry with a bad token
      }
      if (!response.ok || !response.body) throw new Error(`SSE HTTP ${response.status}`)

      failures = 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                                // server closed cleanly → reconnect
        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')) !== -1) {
          const frame = parseFrame(buffer.slice(0, end))
          buffer = buffer.slice(end + 2)

          if (frame.id) lastEventId = frame.id
          if (frame.event !== 'notification') continue // skip heartbeat / comments

          try {
            opts.onNotification(JSON.parse(frame.data) as StreamNotification)
          } catch {
            /* ignore a malformed frame, keep the stream */
          }
        }
      }
      scheduleReconnect()
    } catch {
      if (stopped) return                              // aborted by stop()
      failures++
      scheduleReconnect()
    }
  }

  void connect()

  return () => {
    stopped = true
    controller?.abort()
    if (retryTimer) clearTimeout(retryTimer)
  }
}

function parseFrame(raw: string): { event: string; data: string; id: string | null } {
  let event = 'message'
  let id: string | null = null
  const data: string[] = []

  for (const line of raw.split('\n')) {
    if (line === '' || line.startsWith(':')) continue // comment
    const colon = line.indexOf(':')
    const field = colon === -1 ? line : line.slice(0, colon)
    const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')

    if (field === 'event') event = value
    else if (field === 'data') data.push(value)
    else if (field === 'id') id = value
  }

  return { event, data: data.join('\n'), id }
}
```

### 5.2 React hook

```ts
// src/hooks/useNotificationStream.ts
export function useNotificationStream() {
  const queryClient = useQueryClient()
  const { token, logout } = useAuth()
  const shownToastIds = useRef(new Set<string>())

  useEffect(() => {
    if (!token) return

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

    const stop = startNotificationStream({
      url: `${import.meta.env.VITE_BASE_URL}/admin/notifications/stream`, // trainer: /trainer/notifications/stream
      getToken: () => token,
      onOpen: refresh,
      onNotification: (notification) => {
        refresh()
        if (shownToastIds.current.has(notification.id)) return
        shownToastIds.current.add(notification.id)
        toast(notification.title ?? '', {
          description: notification.body ?? undefined,
          action: linkFor(notification.type, notification.entity), // §11
        })
      },
      onUnauthorized: logout,
    })

    return stop // runs on unmount, logout, and token change
  }, [token, queryClient, logout])
}
```

Mount it **once**, in the top-level authenticated layout or provider, never per page.

---

## 6. Connection lifecycle rules (all apps)

### 6.1 One stream per tab

- Open it after login, once the profile is loaded. Close it on logout, user switch, and unmount.
- Make sure the cleanup runs. React StrictMode mounts effects twice in development, and the returned cleanup handles that.
- The server allows **10 streams per account**. An 11th tab gets `429`. Hitting that usually means streams are leaking because cleanup didn't run.

### 6.2 The server closes streams on purpose. That's normal.

| When | What you see | What to do |
|---|---|---|
| About every hour (randomised per connection) | `EventSource`: automatic reconnect. `fetch`: `done === true`. | Reconnect after about 5 s. The §4/§5 code already does this. |
| When the JWT expires | Same clean close, then the reconnect gets `401` | Refresh the session or sign out |
| On a backend deploy | Clean close | Reconnect |

### 6.3 Missed notifications

1. **Always refetch REST when the stream opens**, both the first time and after every reconnect. This is the guaranteed recovery path.
2. As a bonus, the server replays that user's events published after the last `id:` you saw. `EventSource` sends it automatically; the §5 client sends it as `?last_id=`. The same notification can therefore arrive through both REST and the stream, so **always de-duplicate by `id`**.

### 6.4 Errors before the stream starts

These come back as JSON with a normal HTTP status, not as SSE:

| Status | Body `message` (en) | Meaning | Client action |
|---|---|---|---|
| `401` | `Unauthenticated, you have to login first` | Token missing, expired, invalid, or logged out | Refresh session or sign out. **Don't retry in a loop.** |
| `403` | `This action is not available for your account type.` | Wrong surface (e.g. a learner token on `/admin/...`) | Fix the URL for the app. Don't retry. |
| `403` | `Your account has been deactivated, contact with the administrator.` | Account or role deactivated | Sign out |
| `429` | `Too many requests, please try again after a while!` | More than 10 open streams for this account | Retry after the `Retry-After` header (30 s) |
| `503` | (none) | That server process is full (rare) | Retry with backoff |

---

## 7. REST details

### 7.1 List

```http
GET /api/v1/notifications?page=1&read_status=unread
Authorization: Bearer <JWT>
Accept: application/json
Accept-Language: ar
```

`read_status` is optional: `read`, `unread`, or omit it for all. The inner key depends on the account type: `user_notifications`, `trainer_notifications`, `super_admin_notifications`, or `supervisor_notifications`.

```json
{
  "status": "success",
  "message": "Notifications retrieved successfully",
  "data": {
    "unread_count": 2,
    "notifications": {
      "user_notifications": {
        "data": [
          {
            "id": "092d579d-402e-4aa2-970d-c507b9ef0ac0",
            "title": "Enrollment confirmed",
            "body": "Your course enrollment and payment were successful.",
            "type": null,
            "created_at": "2026 Sep 27, 08:44 AM",
            "is_read": false,
            "notify_id": null,
            "additional_data": {
              "event": "course.enrollment_confirmed",
              "resource": { "type": "course", "id": "course-public-uuid" },
              "course_id": 12
            }
          }
        ],
        "links": { "first": "…", "last": "…", "prev": null, "next": "…" },
        "meta": { "current_page": 1, "last_page": 3, "per_page": 15, "total": 40 }
      }
    }
  }
}
```

**The REST item and the stream event are shaped differently.** Map both into one model (§8):

| Meaning | Stream event | REST item |
|---|---|---|
| id | `id` | `id` (same value) |
| event name | `type` | `additional_data.event` (REST `type` is usually `null`) |
| click target | `entity` | `additional_data.resource` |
| extra context | `data` | the rest of `additional_data` |
| read state | `readAt` (always `null`) | `is_read` |
| time | `createdAt`, ISO UTC | `created_at`, a **pre-formatted display string**, not ISO |

### 7.2 Unread count (new)

```http
GET /api/v1/notifications/unread-count
```
```json
{ "status": "success", "message": "Notifications retrieved successfully", "data": { "count": 3 } }
```

Use this for the badge after each stream event, instead of refetching the whole list when only the badge is visible.

### 7.3 Mark one as read (new)

```http
PATCH /api/v1/notifications/092d579d-402e-4aa2-970d-c507b9ef0ac0/read
```
```json
{ "status": "success", "message": "Notification marked as read", "data": null }
```

This returns `404` if the id doesn't exist **or belongs to another user**.

### 7.4 Mark all as read (new)

```http
PATCH /api/v1/notifications/read-all
```
```json
{ "status": "success", "message": "All notifications marked as read", "data": { "count": 2 } }
```

---

## 8. One model for list + stream (recommended)

```ts
export type NotificationItem = {
  id: string
  type: string
  entity: { type: string; id: string } | null
  title: string
  body: string
  context: Record<string, unknown>
  isRead: boolean
  createdAtLabel: string // display text
}

export function fromStream(n: StreamNotification): NotificationItem {
  return {
    id: n.id,
    type: n.type,
    entity: n.entity,
    title: n.title ?? '',
    body: n.body ?? '',
    context: n.data ?? {},
    isRead: n.readAt !== null,
    createdAtLabel: formatDate(n.createdAt), // your i18n date formatter
  }
}

export function fromRest(item: RestNotification): NotificationItem {
  const { event, resource, ...context } = item.additional_data ?? {}
  return {
    id: item.id,
    type: item.type ?? event ?? 'general',
    entity: resource ? { type: String(resource.type), id: String(resource.id) } : null,
    title: item.title ?? '',
    body: item.body ?? '',
    context,
    isRead: item.is_read,
    createdAtLabel: item.created_at, // already formatted by the API
  }
}
```

Treat the REST list and `unread_count` as the source of truth. After a stream event, invalidate or refetch them; don't increment a local counter forever.

---

## 9. Optional: native `EventSource` without a proxy (tickets)

Only for clients that can't use a server-side proxy **and** can't use `fetch` streaming:

1. `POST /notifications/stream-ticket` with the bearer token returns `{"ticket": "…", "expires_in": 60}`.
2. `new EventSource(`${API}/notifications/stream?ticket=${ticket}`)`. The frames are identical to §3.
3. The ticket can be reused for 60 s, so the browser's automatic retry works in that window. After `readyState === CLOSED`, get a new ticket first.

The storefront should use the proxy (§4) and the dashboards should use `fetch` (§5). Never put the JWT itself in a URL.

---

## 10. Event catalogue (`type`)

| `type` | Recipients | When | `entity.type` |
|---|---|---|---|
| `course.enrollment_confirmed` | Learner, admins | Course checkout/payment succeeds | `course` |
| `program.enrollment_confirmed` | Learner, admins | Program checkout/payment succeeds | `program` |
| `consultation.booking_confirmed` | Learner, admins | Consultation booking/payment succeeds | `consultation_booking` |
| `consultation.booking_created` | Assigned trainer | A learner books the trainer's consultation | `consultation_booking` |
| `book_order.created` | Learner, admins | Physical book checkout succeeds | `order` |
| `book.purchase_confirmed` | Learner, admins | Digital book/research purchase succeeds | `book` |
| `book_order.delivered` | Buyer, admins | Admin marks a physical order delivered | `order` |
| `course.payment_failed`, `consultation_booking.payment_failed`, `order.payment_failed`, `payment.payment_failed` | Buyer, admins | A payment is recorded as failed | matches the prefix |
| `course.session_scheduled`, `program.session_scheduled` | Enrolled learners, trainer, admins | A session is scheduled | `class_session` |
| `course.session_starting_soon`, `program.session_starting_soon` | Trainer | A session is about to start (`data.starts_at`) | `class_session` |
| `consultation.starting_soon` | Trainer | A consultation is about to start (`data.starts_at`) | `consultation_booking` |
| `course.assigned`, `program.assigned` | Assigned trainer | Admin assigns/reassigns content | `course` / `program` |
| `course.review_submitted` | Admins | Trainer submits a course for review | `course` |
| `program.review_submitted` | Admins | Trainer submits a program for review | `program` |
| `course.approved`, `program.approved` | Assigned trainer | Admin approves content | `course` / `program` |
| `course.revision_requested`, `program.revision_requested` | Assigned trainer | Admin requests revisions | `course` / `program` |
| `review.created` | Related trainer, admins | Learner reviews a course, program, or consultation | `review` |
| `consultation.meeting_link_available` | Learner | Meeting link provided | `consultation_booking` |
| `course.certificate_issued`, `program.certificate_issued` | Learner, admins | A certificate is issued | `certificate` |
| `admin.message_sent` | Chosen recipients | Admin sends a broadcast or targeted message | `null` |

**Unknown `type`:** show it normally in the list and toast, and make clicking it a no-op. New backend events must never break older frontends.

---

## 11. Click routing

Route on `entity.type` + `entity.id` (the public UUID). Pick the screen per app and role:

| `entity.type` | Storefront (learner) | Trainer dashboard | Admin dashboard |
|---|---|---|---|
| `course` | Course details / my course | Course workspace | Course review / details |
| `program` | Program details / my program | Program workspace | Program review / details |
| `class_session` | Session inside its course/program (`data.owner` has the owner type and id when present) | Session in the workspace | Session details |
| `consultation_booking` | My consultation | Trainer consultation | Booking details |
| `order` | My orders → detail | — | Book order detail |
| `book` | Book details / my library | — | Book details |
| `review` | — | Related content's reviews | Reviews moderation |
| `certificate` | My certificates | — | Certificates |
| `null` | No navigation (open the notification centre) | same | same |

Never parse `title`/`body` to decide navigation.

---

## 12. Local development

- **CORS:** the stream endpoints accept `http://localhost:*`, `http://127.0.0.1:*`, `https://bateel-dashboard.react.aait-d.com`, and `https://bateel.react.aait-d.com`, with credentials and any request header. You can point local dashboards straight at stage.
- **Against a local backend** (`php artisan serve`): the stream endpoints answer with an empty stream plus `retry: 30000`, and nothing live arrives. That's expected without the stream server. Your code should simply reconnect and refetch REST. To get live events locally, the backend developer runs `php artisan notifications:serve-stream` (needs Redis) and you connect to its port.

---

## 13. How to test on stage

**Quick check with curl:**

```bash
curl -N -H "Authorization: Bearer $TOKEN" \
  https://bateel-back.elkady.aait-d.com/api/v1/user/notifications/stream
```

You should see `: connected` and `retry: 5000` immediately, then `event: heartbeat` every 30 s.

**Trigger a real notification:** log in to the admin dashboard and send one via **Notifications → Send** (`POST /admin/notifications/send`):

```json
{ "title": "Test", "body": "Hello from admin", "target": "targeted", "user_ids": [<your user id>] }
```

**Acceptance checklist:**

- [ ] The stream request stays open (DevTools → Network shows `text/event-stream`, pending) and receives a heartbeat every 30 s.
- [ ] A notification sent to me shows a toast within about 1 s, and the badge updates.
- [ ] The same item appears in the notification centre after a page refresh, with the same `id` and no duplicate toast.
- [ ] A notification sent to a different user never shows up in my stream.
- [ ] Learner token on `/admin/notifications/stream` returns `403` and the app does not retry in a loop.
- [ ] Offline test: turn the network off, send a notification, turn it back on. The list refetch shows it, with at most one toast.
- [ ] Logout closes the stream (Network tab). Logging in as another user never shows the previous user's events.
- [ ] Leaving a tab open for over an hour: the stream reconnects by itself and nothing breaks.
- [ ] Mark one read, mark all read, and clear all update the badge via `unread-count`.

---

## 14. FAQ

**Why not open the stream directly from the storefront browser?**
`EventSource` can't send `Authorization`, and a JWT must never go in a URL. The Next.js proxy keeps the token server-side. The dashboards already hold the token in JS, so they use `fetch`.

**Can I rely on SSE alone for the notification centre?**
No. SSE is a real-time hint. The REST list is the durable history and the source of the unread count, so always refetch it when the stream opens.

**Do I need to call the ticket endpoint anymore?**
No, unless you're using §9.

**What does the stream look like while nothing happens?**
One `event: heartbeat` every 30 s. That's how you can tell the connection is healthy.

**Is there still a limit on how many users can be connected?**
There's no small global limit any more; stage handles hundreds of concurrent streams. The only per-user limit is 10 open streams per account.
