Real-time events (SSE)
The back-office Server-Sent Events stream — connection, authentication, resume, heartbeat, event envelope, permission-filtered event catalogue and reconnection guidance.
The back-office keeps one Server-Sent Events connection per open tab to receive business events as they happen: new orders to confirm, status changes, check-ins, tours, delegate positions, stock alerts, pending accounts, returns and claims (SYN-07, FLD-13, cahier §4.1 « mises à jour automatiquement après les événements métiers », §6.4 « en temps réel »). Events come from the OutboxEvent table after commit, so a stream event is never sent for a rolled-back change. Resellers have their own stream (events.stream); everything important is also a notification, so nothing depends on the stream being connected (notifications matrix).
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| GET | /admin/events | admin.events.streamServer-Sent Events stream: order.created, order.status_changed, visit.checked_in, visit.completed, tour.started, tour.closed, delegate.position, stock.alert, account.pending | any | BO | V1 |
GET /admin/events
admin.events.stream · Permission any back-office user (events filtered per permission) · Apps BO · Release V1 · Requirements SYN-07 · FLD-13
Purpose. A long-lived text/event-stream response carrying the events the caller may see.
Request
| Header / query | Value | Notes |
|---|---|---|
Cookie | session cookie | Same authentication as every back-office call (browser EventSource with withCredentials: true) |
Accept | text/event-stream | |
Last-Event-ID | last id received | Sent automatically by EventSource on reconnection; resume |
?types= | comma-separated event types | Optional narrowing (e.g. the live map asks delegate.position,tour.started,tour.suspended,tour.resumed,tour.closed,visit.checked_in) |
?tourId= | Uuid | Optional: delegate.position only for this tour (tour detail page) |
Response 200 OK, Content-Type: text/event-stream, Cache-Control: no-store, X-Accel-Buffering: no. The first frame is retry: 3000. A comment heartbeat : ping is sent every 25 s; the server closes the connection after 1 hour (the client reconnects transparently).
Event frame:
id: 1847263
event: order.status_changed
data: {"type":"order.status_changed","at":"2026-11-03T09:52:31Z","entity":{"type":"Order","id":"0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e","number":"CMD-2026-000158"},"payload":{"from":"SUBMITTED","to":"VALIDATED","resellerId":"0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a","actor":{"id":"01929e66-3d4e-7f5a-8b6c-7d8e9f0a1b2c","displayName":"Sofiane Hamdi"}}}interface StreamEvent {
type: string; // same as the SSE `event:` field
at: Iso; // commit time of the change
entity: { type: string; id: Uuid; number?: string }; // Prisma model and business number
payload: Record<string, unknown>; // minimal data per event (below); never purchase prices or quantities the caller may not see
}id is the monotonically increasing outbox sequence (a decimal string). Events of one entity arrive in commit order.
Errors
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHENTICATED | No or expired session (the stream also ends when the session is revoked) |
| 403 | PERMISSION_DENIED | Delegate or reseller session (they use events.stream) |
| 429 | STREAM_LIMIT_REACHED | More than 5 open streams for the user — details: { max: 5 } |
Rules & side effects
- Resume. The server keeps the last 24 hours of events (Redis stream). With a known
Last-Event-IDit replays every missed event the caller may see, then continues. If the id is older than the buffer, it sends onestream.resyncand continues from now — the client then re-fetches its open screens. - Permission filter. Each event has a required permission (table below), evaluated at send time with the user's current permissions.
export.ready,import.finishedandme.permissions_changedgo only to the user concerned. - Throttling.
delegate.positionis sent at most once per 30 s per delegate;dashboard.invalidatedat most once per 10 s per user. - Reconnection guidance for the dashboard. Use one shared
EventSourceper tab (a React context),retryhonoured by the browser; onerrorwith the connection closed, back off 3 s, 6 s, 12 s… up to 60 s with ±20 % jitter; after a401, stop and send the user to sign-in; onvisibilitychangeto visible after more than 5 minutes hidden, reconnect and letLast-Event-IDreplay. TanStack Query caches are updated from payloads where possible (setQueryData), otherwise invalidated by query key. - Not audited; no side effects.
Used by BO-03 (shell badges) · BO-04 to BO-08 · BO-41 · BO-42 · BO-13 · BO-23 · BO-55 · BO-69
Event catalogue
| Event | Emitted when | Required permission | Payload |
|---|---|---|---|
order.created | An order leaves DRAFT (first SUBMITTED or PENDING), any channel | orders.view | channel, status, resellerId, storeName, totalTtc, isFirstOrder |
order.to_confirm | An order enters the confirmation queue (SUBMITTED or PENDING) | orders.validate | status, kind (NEW/ON_HOLD/ASSISTED), pendingReasons, channel, storeName, totalTtc, controls (4 statuses), submittedAt |
order.status_changed | Any order transition, and delivery status changes | orders.view (or delivery.view) | from, to, deliveryStatus?, resellerId, actor |
order.modified | Controlled line modification | orders.view | changesCount, totalTtcBefore, totalTtcAfter |
return.requested | A return request is created (reseller, claim or door refusal) | orders.view or support.view | orderId, orderNumber, resellerId, origin, itemsCount, amountTtc |
return.status_changed | A return moves on: decision (approved / rejected), transport, reception, refund. Added in contract 1.1.0 | orders.view or support.view | orderId, resellerId, from, to, actor |
visit.checked_in | Check-in recorded | field.view | tourId, delegateId, resellerId, distanceMeters, withinTolerance, position |
visit.completed | Visit DONE | field.view | tourId, delegateId, resellerId, durationMinutes, interestLevel?, nextAction? |
visit.not_done | Visit NOT_DONE | field.view | tourId, delegateId, resellerId, reasonCode, priority |
tour.started | Tour IN_PROGRESS | field.view | delegateId, date, startPosition?, stops |
tour.suspended · tour.resumed | Delegate pauses / resumes | field.view | delegateId, reasonCode? |
tour.closed | Tour COMPLETED | field.view | delegateId, visitsDone, visitsPlanned, km, revenueHt |
tour.reopened · tour.cancelled | Back-office reopening / cancellation | field.view | delegateId, reason |
delegate.position | Location ping batch received for a tour IN_PROGRESS | field.view | delegateId, tourId, position, progress |
stock.alert | A stock level changes alertLevel | stock.view | variantId, sku, warehouseCode, level (LOW/OUT/OK), available, threshold |
account.pending | A record enters the validation queue | crm.validate | resellerId, number, storeName, kind, declaredCustomerType, hasDuplicateHints |
ticket.created | A ticket is opened (including AFTER_SALES claims) | support.view | number, type, resellerId, storeName, orderNumber?, itemsCount? |
payment.declared | Declaration or cash collection PENDING_VERIFICATION | finance.view | number, method, amount, resellerId, source |
export.ready · import.finished | Own job finished | requester only | jobId, status, source or type, rowCount? |
dashboard.invalidated | An event changed KPI aggregates, or a layout changed | dashboard.view | cards: string[], layout?: boolean |
me.permissions_changed | The user's roles or a role's permissions changed | the user | permissionsVersion — re-fetch me.get |
stream.resync | Resume id too old | everyone | reason: "BUFFER_EXCEEDED" |
Events added to the brief's original list (order.created, order.status_changed, visit.checked_in, visit.completed, visit.not_done, tour.started, tour.closed, delegate.position, stock.alert, account.pending, ticket.created, payment.declared): order.to_confirm, order.modified, return.requested, tour.suspended, tour.resumed, tour.reopened, tour.cancelled, export.ready, import.finished, dashboard.invalidated, me.permissions_changed, stream.resync.
order.created
{ "type": "order.created", "at": "2026-11-03T08:12:00Z", "entity": { "type": "Order", "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "number": "CMD-2026-000158" }, "payload": { "channel": "RESELLER_APP", "status": "SUBMITTED", "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "storeName": "Phone Store El Bahia", "totalTtc": 3974600, "isFirstOrder": false } }order.to_confirm
{ "type": "order.to_confirm", "at": "2026-11-03T08:12:00Z", "entity": { "type": "Order", "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "number": "CMD-2026-000158" }, "payload": { "status": "SUBMITTED", "kind": "NEW", "pendingReasons": [], "channel": "RESELLER_APP", "storeName": "Phone Store El Bahia", "totalTtc": 3974600, "controls": { "stock": "INSUFFICIENT", "price": "OK", "credit": "EXCEEDED", "address": "OK" }, "submittedAt": "2026-11-03T08:12:00Z" } }order.status_changed
{ "type": "order.status_changed", "at": "2026-11-04T10:05:11Z", "entity": { "type": "Order", "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "number": "CMD-2026-000158" }, "payload": { "from": "PREPARING", "to": "READY", "deliveryStatus": "READY", "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "actor": { "id": "01929e77-4e5f-7a6b-8c7d-8e9f0a1b2c3d", "displayName": "Lamia Kaci" } } }order.modified
{ "type": "order.modified", "at": "2026-11-03T09:50:02Z", "entity": { "type": "Order", "id": "0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e", "number": "CMD-2026-000158" }, "payload": { "changesCount": 1, "totalTtcBefore": 3974600, "totalTtcAfter": 3074960 } }return.requested
{ "type": "return.requested", "at": "2026-11-06T09:14:00Z", "entity": { "type": "ReturnRequest", "id": "01931a20-6b7c-7d8e-9f01-2a3b4c5d6e7f", "number": "RET-2026-000012" }, "payload": { "orderId": "01930170-1c2d-7e3f-8a4b-5c6d7e8f9a0b", "orderNumber": "CMD-2026-000157", "origin": "RESELLER", "itemsCount": 1, "amountTtc": 124950 } }return.status_changed
{ "type": "return.status_changed", "at": "2026-11-06T11:02:00Z", "entity": { "type": "ReturnRequest", "id": "01931a20-6b7c-7d8e-9f01-2a3b4c5d6e7f", "number": "RET-2026-000012" }, "payload": { "orderId": "01930170-1c2d-7e3f-8a4b-5c6d7e8f9a0b", "resellerId": "0192f4d2-3e4f-7a5b-9c6d-7e8f9a0b1c2d", "from": "REQUESTED", "to": "APPROVED", "actor": "01929e77-4e5f-7a6b-8c7d-8e9f0a1b2c3d" } }Projected from the outbox events return.approved, return.rejected, return.received and return.status_changed; Retours et SAV (BO-69) refreshes its queue and the open return on it instead of polling. actor is the id of the user who made the move (null for a system move such as the refund after the credit note).
visit.checked_in
{ "type": "visit.checked_in", "at": "2026-11-04T08:41:15Z", "entity": { "type": "Visit", "id": "01930a10-5e6f-7a8b-9c0d-1e2f3a4b5c6e", "number": "VIS-2026-001234" }, "payload": { "tourId": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "delegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "distanceMeters": 63, "withinTolerance": true, "position": { "lat": 35.7209, "lng": -0.5446, "accuracy": 14 } } }visit.completed
payload: { "tourId", "delegateId", "resellerId", "durationMinutes": 32, "interestLevel": "HIGH", "nextAction": "PHONE_FOLLOW_UP" }.
visit.not_done
payload: { "tourId", "delegateId", "resellerId", "reasonCode": "STORE_CLOSED", "priority": "NORMAL" }.
tour.started
payload: { "delegateId", "date": "2026-11-04", "startPosition": { "lat": 35.6969, "lng": -0.6331 }, "stops": 9 }.
tour.suspended
payload: { "delegateId", "reasonCode": "BREAK" }.
tour.resumed
payload: { "delegateId" }.
tour.closed
payload: { "delegateId", "visitsDone": 8, "visitsPlanned": 9, "km": 46.3, "revenueHt": 3080000 }.
tour.reopened
payload: { "delegateId", "reason": "Rapport de visite manquant." }.
tour.cancelled
payload: { "delegateId", "reason": "Délégué en arrêt maladie." }.
delegate.position
{ "type": "delegate.position", "at": "2026-11-04T10:09:31Z", "entity": { "type": "User", "id": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12" }, "payload": { "delegateId": "0192a7d3-1b2c-7d4e-8f5a-6b7c8d9e0f12", "tourId": "01930a10-3c4d-7e5f-8a6b-7c8d9e0f1a2b", "position": { "lat": 35.6923, "lng": -0.5871, "accuracy": 11, "capturedAt": "2026-11-04T10:09:30Z" }, "progress": { "done": 4, "notDone": 1, "remaining": 4 } } }stock.alert
{ "type": "stock.alert", "at": "2026-11-03T09:52:31Z", "entity": { "type": "ProductVariant", "id": "0192b215-7a8b-7c9d-8e0f-1a2b3c4d5e6f" }, "payload": { "variantId": "0192b215-7a8b-7c9d-8e0f-1a2b3c4d5e6f", "sku": "AF-A29-50W-WH", "warehouseCode": "WH-ORN", "level": "OUT", "available": 0, "threshold": 20 } }account.pending
payload: { "resellerId", "number": "REV-000245", "storeName": "Mobile Center Hydra", "kind": "SELF_SIGNUP", "declaredCustomerType": "DETAIL", "hasDuplicateHints": true }.
ticket.created
{ "type": "ticket.created", "at": "2026-11-10T09:12:00Z", "entity": { "type": "Ticket", "id": "01931b30-7c8d-7e9f-8a01-3b4c5d6e7f80", "number": "TKT-2026-000045" }, "payload": { "type": "AFTER_SALES", "resellerId": "0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a", "storeName": "Phone Store El Bahia", "orderNumber": "CMD-2026-000158", "itemsCount": 1 } }payment.declared
payload: { "number": "PAY-2026-000079", "method": "CCP", "amount": 1074960, "resellerId", "source": "RESELLER_DECLARATION" }.
export.ready
payload: { "jobId", "status": "SUCCEEDED", "source": { "kind": "LIST", "key": "resellers" }, "rowCount": 94 }.
import.finished
payload: { "jobId", "status": "SUCCEEDED", "type": "PRODUCTS", "mode": "DRY_RUN" }.
dashboard.invalidated
payload: { "cards": ["revenue.today", "orders.byStatus", "orders.toConfirm"], "layout": false }.
me.permissions_changed
payload: { "permissionsVersion": 8 } — re-fetch me.get and re-render navigation.
stream.resync
payload: { "reason": "BUFFER_EXCEEDED" } — re-fetch every open list and detail.
Sample transcript
GET /v1/admin/events HTTP/1.1
Accept: text/event-stream
Cookie: hoco.session_token=…
Last-Event-ID: 1847250
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
retry: 3000
id: 1847251
event: order.created
data: {"type":"order.created","at":"2026-11-03T08:12:00Z","entity":{"type":"Order","id":"0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e","number":"CMD-2026-000158"},"payload":{"channel":"RESELLER_APP","status":"SUBMITTED","resellerId":"0192f4b8-61c2-7a3e-9b10-1f2e3d4c5b6a","storeName":"Phone Store El Bahia","totalTtc":3974600,"isFirstOrder":false}}
id: 1847252
event: order.to_confirm
data: {"type":"order.to_confirm","at":"2026-11-03T08:12:00Z","entity":{"type":"Order","id":"0193016e-4b2a-7c11-9d8e-7f6a5b4c3d2e","number":"CMD-2026-000158"},"payload":{"status":"SUBMITTED","kind":"NEW","pendingReasons":[],"channel":"RESELLER_APP","storeName":"Phone Store El Bahia","totalTtc":3974600,"controls":{"stock":"INSUFFICIENT","price":"OK","credit":"EXCEEDED","address":"OK"},"submittedAt":"2026-11-03T08:12:00Z"}}
: ping
id: 1847260
event: dashboard.invalidated
data: {"type":"dashboard.invalidated","at":"2026-11-03T08:12:05Z","entity":{"type":"Dashboard","id":"01929e55-2c3d-7e4f-9a1b-2c3d4e5f6a7b"},"payload":{"cards":["orders.toConfirm","orders.byStatus"],"layout":false}}
: pingAdministration, settings & audit
Back-office administration — users, sessions, roles and permissions with guard rails, the activity journal, business settings, reference lists, app versions, support regions, data imports and global search.
Jobs and scheduler
The worker process, the BullMQ queues with their retry and dead-letter policies, the transactional outbox dispatcher, and every scheduled job with its cron in Africa/Algiers, idempotency, failure handling, monitoring, owner and release.