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>
9.5 KiB
title, tags
| title | tags | ||||
|---|---|---|---|---|---|
| Socket Events |
|
Socket Events
Last updated: 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in 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:
emitGlobalEvent(event, data); // io.emit
emitToRoom(room, event, data); // io.to(room).emit
setSocketServer(server);
getSocketServer();
Connection
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-cancelledevent. When a purchase request is cancelled,PurchaseRequestServiceemitspurchase-request-updatewitheventType: 'status-changed'to therequest-<requestId>room. Any code listening forrequest-cancelledwill 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, chatController.ts, and app.ts socket handlers.
Note: There is no
notification-readevent. Cross-tab unread badge synchronisation is handled byunread-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.
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 byauthControllerwhen a referred user completes registration, not byPointsService. It belongs to the authentication domain.PointsServiceemits onlylevel-upandreferral-reward.
Disputes
⚠️ TODO stubs —
DisputeServicedoes not currently emit any Socket.IO events. All socket event handlers inDisputeServiceare 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:
- On reconnect, re-emit every
join-*for the rooms the user cares about. - Re-fetch any state that may have moved while disconnected (
GET /api/notifications/unread-count, the active purchase request, chat history with?before=<lastSeen>). - 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)
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).