Files
nick-doc/03 - API Reference/Socket Events.md
Siavash Sameni 9698ec5809 docs: align API reference and data model docs with code reality
API Reference (9 files updated):
- Marketplace API: corrected offer endpoints (scoped under /purchase-requests/:id/offers),
  marked phantom /search /stats /seller/:sellerId /withdraw routes as NOT IMPLEMENTED,
  documented PUT→PATCH mismatches, removed invalid SellerOffer 'active' status
- Dispute API: corrected resolve schema (action enum), categories (no 'fraud'),
  removed 'under_review' status, added security callouts (3 unguarded endpoints),
  route shadowing documented, all socket events marked as TODO stubs
- Notification API: corrected mark-all-read method+path, fixed broken GET /:id,
  added unread-count-update event, 90-day TTL documented
- Payment API: /create→/save, removed 10+ phantom endpoints, fixed release/refund
  paths (no /shkeeper/ segment), added 3 unauthenticated endpoint security warnings,
  stats undercounting documented, export privilege gap documented
- Authentication API: 8-digit→6-digit code, no-complexity warning on reset-with-code,
  rate limiter counts all attempts, passkey stub claims removed, deleteAccount bug noted
- Admin API: PUT→PATCH bug documented, wrong status values documented, hard vs soft
  delete clarified, scanner no-auth security bug, 3 NOT IMPLEMENTED endpoints
- Chat API: file upload wrong endpoint bug, archive PUT→PATCH bug, rate limits added
- Points API: corrected redeem schema, referral triggers on 'completed' only,
  leaderboard period ignored, removed 'refund' PointTransaction type
- Socket Events: removed request-cancelled, notification-read; added unread-count-update;
  dispute events all stubs; referral-signup is auth-domain not points-domain

Data Models (3 files updated):
- SellerOffer: removed 'active' from status enum, withdrawOffer() is dead code
- PurchaseRequest: added pending_payment/active statuses, added 'urgent' urgency,
  corrected description minimum (5 chars), removed finalized/archived
- Dispute: corrected action enum, categories (no fraud), removed under_review,
  security callout on unguarded status/resolve endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:57:47 +04:00

164 lines
9.5 KiB
Markdown

---
title: Socket Events
tags: [api, socket, realtime, reference]
---
# Socket Events
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in [`backend/src/app.ts`](../../backend/src/app.ts) and exposed globally as `global.io`. Helper functions for emitting events from services live in [`backend/src/infrastructure/socket/socketService.ts`](../../backend/src/infrastructure/socket/socketService.ts):
```ts
emitGlobalEvent(event, data); // io.emit
emitToRoom(room, event, data); // io.to(room).emit
setSocketServer(server);
getSocketServer();
```
## Connection
```ts
import { io } from "socket.io-client";
const socket = io(process.env.NEXT_PUBLIC_API_URL!, {
withCredentials: true,
transports: ["websocket"],
});
```
The CORS policy mirrors the REST API: only `FRONTEND_URL` is allowed, and credentials are enabled. There is no token-based handshake — the server identifies users by the `join-*` events the client emits after connecting. (A future improvement would authenticate via the JWT on handshake; for now, ownership is checked at the REST layer when a mutation triggers an event.)
The server logs `🔌 User connected: <socketId>` on connect and `🔌 User disconnected: <socketId>` on disconnect. There is no broadcast `user-offline` on disconnect — see "Online status" below.
## Rooms
Rooms are the targeting mechanism. The client joins/leaves rooms with these events:
| Client → Server | Room joined |
| --- | --- |
| `join-user-room` (userId) | `user-<userId>` |
| `join-request-room` (requestId) | `request-<requestId>` |
| `leave-request-room` (requestId) | leaves `request-<requestId>` |
| `join-seller-room` (sellerId) | `seller-<sellerId>` + `sellers` (global) |
| `leave-seller-room` (sellerId) | leaves both |
| `join-buyer-room` (buyerId) | `buyer-<buyerId>` + `buyers` (global) |
| `leave-buyer-room` (buyerId) | leaves both |
| `join-chat-room` (chatId) | `chat-<chatId>` |
| `leave-chat-room` (chatId) | leaves `chat-<chatId>` |
| `user-online` (userId) | joins `user-<userId>`, broadcasts `user-status-change` |
| `typing-start` ({ chatId, userId, userName }) | broadcasts `user-typing` to `chat-<chatId>` |
| `typing-stop` ({ chatId, userId }) | broadcasts `user-typing` (isTyping=false) |
Joining a room is unauthenticated — clients are expected to only join their own rooms. Sensitive data is filtered at the REST layer that emits the event.
## Server → Client events
Grouped by the service that emits them.
### Marketplace
| Event | Room | Payload | Source |
| --- | --- | --- | --- |
| `new-purchase-request` | `sellers` (shared global room) | `PurchaseRequest` document | `marketplaceController.createPurchaseRequest` |
| `new-offer` | `buyer-<buyerId>` | `{ requestId, offer, sellerId }` | `marketplaceController.createSellerOffer` |
| `seller-offer-update` | `seller-<sellerId>` (and global on payment confirm) | `{ sellerId, requestId, eventType: "payment-completed" \| "offer-rejected" \| "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } }` | `marketplaceController`, `shkeeperRoutes`, `shkeeperWebhook`, `SellerOfferService` |
| `purchase-request-update` | `request-<requestId>` | `{ type, requestId, status?, paymentId?, txHash?, provider? }` | `marketplaceController`, `PurchaseRequestService`, `shkeeperRoutes`, `paymentCoordinator` |
| `transaction-completed` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, paymentId, amount, currency }` | `marketplaceController` |
| `delivery-code-generated` | `request-<requestId>` | `{ requestId, deliveryCode }` (only the seller UI uses this) | `DeliveryService` |
| `delivery-update` | `request-<requestId>` | `{ requestId, status, carrier?, trackingNumber? }` | `DeliveryService` |
| `delivery-confirmed` | `request-<requestId>` | `{ requestId }` | `DeliveryService` |
| `buyer-confirmed-delivery` | `user-<sellerId>` | `{ requestId, buyerId }` | `DeliveryService` |
| `template-checkout-payment-confirmed` | global + `template-checkout-<id>` | `{ checkoutId, requestIds, paymentId }` | `templateCheckoutWebhook`, `paymentCoordinator` |
| `template-checkout-payment-pending` | global | `{ checkoutId }` | `templateCheckoutWebhook` |
| `template-checkout-payment-failed` | global | `{ checkoutId, reason }` | `templateCheckoutWebhook` |
> **Note:** There is **no** `request-cancelled` event. When a purchase request is cancelled, `PurchaseRequestService` emits `purchase-request-update` with `eventType: 'status-changed'` to the `request-<requestId>` room. Any code listening for `request-cancelled` will never fire.
### Payment
| Event | Room | Payload | Source |
| --- | --- | --- | --- |
| `payment-created` | global | `{ paymentId, provider, requestId, buyerId, sellerId, amount, currency }` | `shkeeperService`, `decentralizedPaymentService` |
| `payment-received` | `user-<sellerId>` | `{ purchaseRequestId, amount, currency, buyerId }` | `paymentRoutes`, `marketplace/routes` |
| `payment-update` | global + room-specific | `{ paymentId, status, escrowState?, txHash? }` | `paymentCoordinator` |
| `payout-created` | global | `{ payoutId, taskId, sellerId, amount, currency }` | `shkeeperPayoutService` |
| `payout-completed` | global, `user-<sellerId>` | `{ payoutId, taskId, txHash }` | `shkeeperPayoutService`, `decentralizedPaymentService` |
| `payout-updated` | global | `{ payoutId, status }` | `shkeeperPayoutService` |
### Chat
| Event | Room | Payload |
| --- | --- | --- |
| `new-message` | `chat-<chatId>` | `{ chatId, message: { _id, content, senderId, createdAt, attachments? }, senderId }` |
| `messages-read` | `chat-<chatId>` | `{ chatId, userId, upToMessageId, modifiedCount }` |
| `message-edited` | `chat-<chatId>` | `{ chatId, messageId, content, editedAt }` |
| `message-deleted` | `chat-<chatId>` | `{ chatId, messageId, deletedAt }` |
| `participants-added` | `chat-<chatId>` | `{ chatId, addedUserIds }` |
| `participant-removed` | `chat-<chatId>` | `{ chatId, removedUserId }` |
| `user-typing` | `chat-<chatId>` | `{ userId, userName?, isTyping }` |
| `user-status-change` | broadcast | `{ userId, status: "online", lastSeen }` |
Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`chatController.ts`](../../backend/src/services/chat/chatController.ts), and `app.ts` socket handlers.
> **Note:** There is **no** `notification-read` event. Cross-tab unread badge synchronisation is handled by `unread-count-update` (see Notification table below), not by a dedicated read event.
### Notification
| Event | Room | Payload |
| --- | --- | --- |
| `new-notification` | `user-<userId>` | `{ notification: { _id, type, title, body, data, createdAt } }` |
| `unread-count-update` | `user-<userId>` | `{ unreadCount }` |
Source: [`NotificationService.ts`](../../backend/src/services/notification/NotificationService.ts).
`unread-count-update` is the canonical cross-tab sync mechanism for the notification badge. It is emitted whenever the unread count changes (new notification or mark-as-read).
### Points
| Event | Room | Payload | Source |
| --- | --- | --- | --- |
| `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` | `PointsService` |
| `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` | `PointsService` |
| `referral-signup` | `user-<referrerId>` | `{ referredUserId, name, joinedAt }` | `authController` (auth domain, **not** `PointsService`) |
> **Note on `referral-signup`** — This event is emitted by `authController` when a referred user completes registration, not by `PointsService`. It belongs to the authentication domain. `PointsService` emits only `level-up` and `referral-reward`.
### Disputes
> ⚠️ **TODO stubs** — `DisputeService` does not currently emit any Socket.IO events. All socket event handlers in `DisputeService` are placeholder stubs. No real-time dispute notifications fire regardless of dispute status changes.
## Online status
A client emits `user-online` after connecting; the server broadcasts `user-status-change` (status `"online"`). There is currently **no** matching offline emit on disconnect because the server does not track which userId belongs to which socketId. To implement presence, store the mapping on connect and emit `user-status-change` (status `"offline"`) in the disconnect handler.
## Reconnection
Socket.IO defaults are used: exponential backoff starting at 1s, capped at 5s, with jitter. The recommended client policy is:
1. On reconnect, re-emit every `join-*` for the rooms the user cares about.
2. Re-fetch any state that may have moved while disconnected (`GET /api/notifications/unread-count`, the active purchase request, chat history with `?before=<lastSeen>`).
3. Treat the first 5 seconds after reconnect as "catching up" in the UI.
The server does not buffer missed events; if delivery guarantees matter, fall back to REST.
## Server-side helpers (for service authors)
```ts
import { emitGlobalEvent, emitToRoom } from "@/infrastructure/socket/socketService";
emitGlobalEvent("new-purchase-request", request);
emitToRoom(`request-${requestId}`, "purchase-request-update", { type: "status_changed", status });
```
Use `emitToRoom` whenever you can — it limits the broadcast surface area. Reserve `emitGlobalEvent` for events that genuinely need every connected client (rare).
## Related
- [[Authentication API]] (token issuance)
- [[Chat API]]
- [[Notification API]]
- [[Marketplace API]]
- [[Payment API]]
- [[Real-time Architecture]]