# SSE Realtime Design

## Goal

Replace HTTP polling (`refetchInterval: 45_000`) with Server-Sent Events (SSE) for real-time updates: chat messages, unread counters, order status changes, admin notifications.

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Technology | SSE over WebSocket | Native browser API, auto-reconnect, simpler server code. Messages still sent via HTTP POST. |
| SSE approach | Direct EventBus bridge (no recovery buffer) | Sufficient for shop scale. EventSource has built-in auto-reconnect. |
| Connection lifecycle | Connect on login, close on logout | SSE created when JWT appears in Effector `$token`, closed on `null`. |
| Auth method | JWT in query param (`?token=`) | EventSource doesn't support custom headers. Server's `authenticate` decorator already handles `request.query?.token`. |

## Server-side

### New file: `server/src/routes/sse.js`

Single SSE endpoint:

```
GET /api/sse/stream?token=JWT
PreHandler: fastify.authenticate
```

**Behavior:**
1. Sets SSE headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`.
2. Sends heartbit comment every 30s: `:heartbit\n\n` (invisible to EventSource, keeps connection alive).
3. Subscribes to `request.server.eventBus`, filters events by user identity, pushes matching events through SSE.
4. On `response.raw.on('close')` — removes EventBus listeners.

**Event mapping (EventBus → SSE):**

| EventBus event | Who receives | SSE `event` type | Payload |
|---|---|---|---|
| `orderMessage:adminReply` | User (order.userId) | `message:new` | `{ orderId, messageId, preview }` |
| `order:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, newStatus }` |
| `payment:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, paymentStatus }` |
| `order:deliveryFeeAdjusted` | User (order.userId) | `order:updated` | `{ orderId }` |
| `orderMessage:sent` | Admin (all admins) | `message:new` | `{ orderId, messageId, preview }` |
| `order:created` | Admin | `order:new` | `{ orderId }` |

**Admin filtering:** If `request.user` is admin (checked via email match), subscribe to all admin events without userId filtering. Currently only one admin exists, so this is straightforward.

### Modified file: `server/src/index.js`

Add import and registration:

```js
import { registerSseRoutes } from './routes/sse.js'
// ...
await registerSseRoutes(fastify)
```

No other server changes needed. Existing `authenticate` decorator (line 75-84) already supports `request.query?.token`.

## Client-side

### New file: `client/src/shared/lib/sse.ts`

Factory function:

```ts
export function createEventStream(token: string): EventSource {
  return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
}
```

### New file: `client/src/app/providers/SseProvider.tsx`

React component that bridges SSE events to React Query cache invalidation:

1. Subscribes to `$token` from Effector (`@/shared/model/auth`).
2. When token appears → creates `EventSource` via `createEventStream(token)`.
3. When token becomes `null` → closes `EventSource`.
4. Registers `onmessage` handlers for each SSE event type:

| SSE event | Handler |
|---|---|
| `message:new` | User side: `invalidateQueries(['me', 'messages', 'unread-count'])`, `invalidateQueries(['me', 'conversations'])`, `invalidateQueries(['me', 'orders', orderId])`. Admin side: `invalidateQueries(['admin', 'orders', orderId])`. |
| `order:statusChanged` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. |
| `order:new` | Admin: `invalidateQueries(['admin', 'orders', 'summary'])`, `invalidateQueries(['admin', 'orders'])`. |
| `order:updated` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. |

React Query's `invalidateQueries` only refetches active (currently mounted) queries. Inactive queries are just marked stale. This means SseProvider can call `invalidateQueries(['me', 'orders', orderId])` unconditionally — it will only refetch if the user has that order's chat page open.

5. Uses `useQueryClient()` to access the query client.

### Modified files

**`client/src/app/providers/AppProviders.tsx`:**
- Add `<SseProvider>` as a child of `QueryClientProvider` (or wrap it around, needs `queryClient`).

**`client/src/pages/me/ui/MeLayoutPage.tsx`:**
- Remove `refetchInterval: 45_000` from the unread count query.
- Remove `refetchOnWindowFocus: true` (revert to global default `false`).

**`client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`:**
- Remove `refetchInterval: 45_000` from the orders summary query.
- Remove `refetchOnWindowFocus: true` (revert to global default `false`).

## Data Flow (example: admin replies to user)

```
Admin → POST /api/admin/orders/:id/messages
  → Prisma: creates OrderMessage
  → EventBus: emit('orderMessage:adminReply', { orderId, userId, messageId, preview })
  → dispatchNotification: email/telegram to user (existing behavior, unchanged)
  → SSE handler: filters by userId, formats as SSE event

Client (SseProvider):
  → SSE event 'message:new' received
  → invalidateQueries(['me', 'messages', 'unread-count'])   → badge updates
  → invalidateQueries(['me', 'conversations'])              → dialog list updates
  → invalidateQueries(['me', 'orders', orderId])            → chat updates (only if that order's query is active)
```

## Files Summary

| File | Status | Description |
|---|---|---|
| `server/src/routes/sse.js` | New | SSE endpoint, EventBus→SSE bridge |
| `server/src/index.js` | Modify | Import and register SSE routes |
| `client/src/shared/lib/sse.ts` | New | EventSource factory |
| `client/src/app/providers/SseProvider.tsx` | New | SSE→ReactQuery bridge component |
| `client/src/app/providers/AppProviders.tsx` | Modify | Mount SseProvider |
| `client/src/pages/me/ui/MeLayoutPage.tsx` | Modify | Remove refetchInterval |
| `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` | Modify | Remove refetchInterval |

## Testing

**Server (vitest):**
- SSE endpoint returns correct headers (`text/event-stream`, `no-cache`, `keep-alive`).
- SSE endpoint sends heartbit comment on connect.
- When EventBus emits an event relevant to the connected user, SSE stream contains the formatted event.
- When EventBus emits an event for a different user, SSE stream does NOT receive it.
- EventSource cleanup: listeners removed on response close.
- Admin receives all admin events regardless of userId.

**Client (vitest + jsdom):**
- `createEventStream(token)` returns EventSource with correct URL including token.
- `SseProvider` creates EventSource when `$token` is set.
- `SseProvider` closes EventSource when `$token` becomes null.
- `SseProvider` calls `queryClient.invalidateQueries` with correct keys on each SSE event type.
- No EventSource created when `$token` is null.
