docs: align flow docs with code reality + create 35 implementation issue files
Flow docs updated (11 files): - Delivery Confirmation: reversed actor roles (buyer generates, seller verifies), fixed endpoint paths (/delivery-code/generate, /delivery-code/verify) - Passkey (WebAuthn): removed stub/simulated-key claims; real @simplewebauthn/server attestation is implemented; refresh tokens are persisted - Dispute: corrected resolve schema (action enum), removed non-existent statuses, documented security gaps (no role guards on status/resolve/assign), route shadowing, all socket events are TODO stubs - Seller Offer: corrected all endpoint paths, removed 'active' status, documented withdraw dead code, missing seller history page, select-offer notification gap - Notification: corrected mark-all-read method+path, fixed GET /:id broken lookup, added unread-count-update socket event - Authentication: corrected rate limiter (counts all attempts), axios 403 not handled, deleteAccount wrong endpoint bug, changePassword no UI - Password Reset: corrected 6-digit code (not 8), documented no-complexity gap on reset-with-code vs token reset - Payment Flow DePay: /create→/save, removed phantom sub-routes, SIM_ bypass risk, PaymentProvider type gap, getProviderIntentEndpoint routing bug - Payment Flow SHKeeper: removed phantom polling endpoint, fixed release/refund paths - Purchase Request: added pending_payment/active statuses, fixed sellers/attachments endpoints, corrected socket events, PUT→PATCH bug - Escrow: documented dispute resolve does not touch escrow, route shadowing, confirm-delivery auth gap Issues created (35 files in Issues/): - 9 security issues (critical) including: dispute privilege escalation ×4, unauthenticated payment/scanner endpoints ×2, SIM_ production bypass, confirm-delivery ownership gap - 26 additional major/critical bugs covering broken endpoints, missing features, data integrity gaps, and frontend-backend mismatches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,17 @@ title: Dispute Flow
|
||||
tags: [flow, dispute, mediator, evidence, chat, state-machine]
|
||||
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
|
||||
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
|
||||
audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts, routes/disputeRoutes.ts (dashboard), services/dispute/disputeRoutes.ts (release-hold), app.ts. Previous version had wrong resolution schema, invented status values, missing security issues, and incorrect socket-event description."
|
||||
---
|
||||
|
||||
# Dispute Flow
|
||||
|
||||
When something goes wrong (item not delivered, wrong item, fraud), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — releasing the escrow to the seller, refunding the buyer, splitting the funds, or rejecting the claim.
|
||||
When something goes wrong (item not delivered, wrong item, seller misbehaviour), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — selecting an action such as refund, replacement, compensation, warning, or ban.
|
||||
|
||||
> [!danger] SECURITY — Three open privilege-escalation bugs exist as of this audit. See [Security Gaps](#security-gaps) below.
|
||||
|
||||
> [!warning] Real-time events not implemented
|
||||
> Every Socket.IO emit in `DisputeService` is currently commented out. No `dispute-updated`, `new-notification`, or any other socket event fires for dispute creation, admin assignment, status changes, evidence uploads, or resolution. The dispute feature is CRUD-only at this stage.
|
||||
|
||||
## Actors
|
||||
|
||||
@@ -15,11 +21,9 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
|
||||
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
|
||||
- **Admin / Mediator** — assigned to investigate.
|
||||
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
|
||||
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts`, and release-hold helpers in `backend/src/services/dispute/releaseHoldService.ts`.
|
||||
> [!note] Alignment gap
|
||||
> The module exists now, but it still uses the legacy status/action enum. [[Funds Ledger and Escrow State Machine Specification]] defines the canonical future dispute states and financial side effects.
|
||||
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts` (mounted first at `/api/disputes`), and release-hold helpers in `backend/src/services/dispute/disputeRoutes.ts` (mounted second at `/api/disputes`).
|
||||
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
|
||||
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned).
|
||||
- **Socket.IO** — no events fire today; all emits are TODO stubs (see warning above).
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -29,63 +33,167 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
|
||||
|
||||
## Dispute state machine (`Dispute.status`)
|
||||
|
||||
Valid status values (from `Dispute.ts`): `pending | in_progress | waiting_response | resolved | rejected | closed`.
|
||||
|
||||
> [!caution] `under_review` does NOT exist. The correct progressed status is `in_progress`.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
|
||||
pending --> in_progress: admin assigned\nassignAdmin()
|
||||
in_progress --> resolved: admin resolves\naction ∈ {refund, partial, release, reject}
|
||||
pending --> waiting_response: status update
|
||||
in_progress --> waiting_response: status update
|
||||
waiting_response --> in_progress: status update
|
||||
in_progress --> resolved: admin resolves\nresolveDispute()
|
||||
in_progress --> rejected: admin rejects
|
||||
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
|
||||
pending --> closed: same
|
||||
resolved --> [*]
|
||||
rejected --> [*]
|
||||
closed --> [*]
|
||||
```
|
||||
|
||||
Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts` *(intended design)*): `refund`, `partial`, `release`, `reject`.
|
||||
## Resolution schema (`Dispute.resolution`)
|
||||
|
||||
```ts
|
||||
resolution?: {
|
||||
action: 'refund' | 'replacement' | 'compensation' | 'warning_seller' | 'ban_seller' | 'no_action';
|
||||
amount?: number;
|
||||
currency?: string; // 'USD' | 'EUR' | 'IRR' | 'USDT'
|
||||
notes?: string;
|
||||
resolvedBy: ObjectId;
|
||||
resolvedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
> [!caution] Incorrect in previous docs: `decision: buyer|seller|split` and `refundAmount` do NOT exist in the model. The field is `action` with the six values listed above.
|
||||
|
||||
## Dispute categories (`Dispute.category`)
|
||||
|
||||
Valid values: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`
|
||||
|
||||
> [!caution] `fraud` is NOT a valid category. Use `seller_behavior` or `other` for fraud-type reports.
|
||||
|
||||
---
|
||||
|
||||
## Security Gaps
|
||||
|
||||
### 1. `PATCH /api/disputes/:id/status` — no role guard
|
||||
|
||||
**File:** `backend/src/routes/disputeRoutes.ts` line 26
|
||||
|
||||
```ts
|
||||
router.patch('/:id/status', DisputeController.updateStatus);
|
||||
```
|
||||
|
||||
Despite comments in the router saying "admin only", there is **no `authorizeRoles` middleware**. Any authenticated buyer or seller can call this endpoint and change a dispute's status to `resolved` or `closed`, bypassing the admin resolution flow entirely. This is an open privilege-escalation bug.
|
||||
|
||||
### 2. `POST /api/disputes/:id/resolve` (dashboard router) — no role guard
|
||||
|
||||
**File:** `backend/src/routes/disputeRoutes.ts` line 29
|
||||
|
||||
```ts
|
||||
router.post('/:id/resolve', DisputeController.resolveDispute);
|
||||
```
|
||||
|
||||
No role guard. Any authenticated user can post a resolution — including `action: 'ban_seller'`. Note that the **release-hold router's** `POST /:purchaseRequestId/resolve` (`backend/src/services/dispute/disputeRoutes.ts` line 77) **does** correctly apply `authorizeRoles('admin')`. The dashboard router's resolve endpoint does not.
|
||||
|
||||
### 3. `POST /api/disputes/:id/assign` — no role guard
|
||||
|
||||
**File:** `backend/src/routes/disputeRoutes.ts` line 23
|
||||
|
||||
```ts
|
||||
router.post('/:id/assign', DisputeController.assignAdmin);
|
||||
```
|
||||
|
||||
Any authenticated user can call this with their own user ID in `{ adminId }` and self-assign as mediator for any dispute.
|
||||
|
||||
---
|
||||
|
||||
## Route Shadowing
|
||||
|
||||
Both routers are mounted at `/api/disputes` in `app.ts`:
|
||||
|
||||
```ts
|
||||
// app.ts line 521 — mounted FIRST
|
||||
app.use("/api/disputes", dashboardDisputeRoutes); // src/routes/disputeRoutes.ts
|
||||
|
||||
// app.ts line 585 — mounted SECOND
|
||||
app.use("/api/disputes", disputeRoutes); // src/services/dispute/disputeRoutes.ts
|
||||
```
|
||||
|
||||
Express evaluates routes in registration order. This creates two concrete hazards:
|
||||
|
||||
1. **`POST /api/disputes/:id/resolve`** — the dashboard router (mounted first) exposes `POST /:id/resolve` with no role guard. A request intended for the release-hold router's `POST /:purchaseRequestId/resolve` (which **does** require admin) will be intercepted and handled by the wrong, unguarded handler when a matching dispute `_id` is supplied.
|
||||
|
||||
2. **`POST /api/disputes/:purchaseRequestId/raise`** — this route exists only in the second (release-hold) router. It will be reached correctly only if the dashboard router does not first match the path. Since the dashboard router has no `/raise` route, requests pass through. However, as more routes are added to either router, collisions will grow silently.
|
||||
|
||||
**Recommendation:** Separate the two routers onto distinct path prefixes (e.g. `/api/disputes` for the dashboard controller, `/api/disputes/hold` for the release-hold service).
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Opening
|
||||
|
||||
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
|
||||
2. They select a `category` (delivery, payment, quality, fraud, other), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
|
||||
2. They select a `category` (`product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
|
||||
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
|
||||
4. Backend `DisputeService.createDispute` (`:12-119`):
|
||||
- Loads the purchase request with `populate('selectedOfferId')`.
|
||||
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId` → `selectedOffer.sellerId` → first of `preferredSellerIds`. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
|
||||
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`.
|
||||
- Creates a **`Chat` of type `group`** with the buyer and the resolved seller as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
|
||||
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId` → `selectedOffer.sellerId` → first of `preferredSellerIds`. Once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
|
||||
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`. The pre-save hook appends an automatic `dispute_created` timeline entry.
|
||||
- Creates a **`Chat` of type `group`** with the buyer (and seller, if resolved) as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
|
||||
- Persists `dispute.chatId = chat._id`.
|
||||
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle.
|
||||
5. **Notifications: none fire.** The notification block is a TODO stub in `DisputeService.createDispute` (`:107-116`).
|
||||
|
||||
> [!note] Release hold behavior
|
||||
> Opening a dispute now has backend release-hold support: `releaseHoldService.raiseDispute()` sets hold fields on the purchase request and related payments, and release/refund gates can consult those fields. The remaining work is to make this the single mandatory policy path for every release/refund/sweep operation and align it with the canonical `DISPUTED` escrow state.
|
||||
> Opening a dispute through the release-hold router (`POST /api/disputes/:purchaseRequestId/raise`) sets hold fields on the purchase request and related payments via `releaseHoldService.raiseDispute()`. Release/refund gates can consult those fields. This is a separate code path from `DisputeService.createDispute` above.
|
||||
|
||||
### Phase 2 — Admin assignment
|
||||
|
||||
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
|
||||
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }` (currently the admin's own id).
|
||||
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }`.
|
||||
|
||||
> [!danger] No role guard on this endpoint — any authenticated user can call it (see [Security Gaps](#security-gaps)).
|
||||
|
||||
8. `DisputeService.assignAdmin` (`:184-223`):
|
||||
- `dispute.adminId = adminId; dispute.status = 'in_progress'`.
|
||||
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
|
||||
- Adds the admin to the dispute `chat.participants[]` (role `admin`).
|
||||
- Saves.
|
||||
- **No socket event fires.** (`// TODO: Notify buyer and seller via Socket.IO`)
|
||||
|
||||
### Phase 3 — Investigation
|
||||
|
||||
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence` — `DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`.
|
||||
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`.
|
||||
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence` — `DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`. **No socket event fires for evidence uploads.**
|
||||
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`. **No socket event fires.**
|
||||
|
||||
> [!danger] `PATCH /api/disputes/:id/status` has no role guard — any authenticated user can change dispute status (see [Security Gaps](#security-gaps)).
|
||||
|
||||
### Phase 4 — Resolution
|
||||
|
||||
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with `{ action, amount?, currency?, notes? }`.
|
||||
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with:
|
||||
```json
|
||||
{
|
||||
"action": "refund | replacement | compensation | warning_seller | ban_seller | no_action",
|
||||
"amount": 150,
|
||||
"currency": "USD",
|
||||
"notes": "Seller failed to deliver item"
|
||||
}
|
||||
```
|
||||
12. `DisputeService.resolveDispute` (`:262-300`):
|
||||
- `dispute.status = 'resolved'`
|
||||
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
|
||||
- `dispute.closedAt = now`
|
||||
- Appends `timeline` entry `dispute_resolved`.
|
||||
- Saves.
|
||||
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **release** ([[Payout Flow]] / [[Escrow Flow]]) or the **refund**. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
|
||||
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`).
|
||||
- **No socket event fires.** (`// TODO: Send notifications via Socket.IO`)
|
||||
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **release** ([[Payout Flow]] / [[Escrow Flow]]) or the **refund** as a separate step. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
|
||||
|
||||
> [!danger] `POST /api/disputes/:id/resolve` (dashboard router) has no role guard — any authenticated user can post any resolution action including `ban_seller` (see [Security Gaps](#security-gaps)).
|
||||
|
||||
---
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
@@ -107,80 +215,106 @@ sequenceDiagram
|
||||
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
|
||||
BE->>DB: dispute.chatId = chat._id
|
||||
BE-->>FE: { dispute }
|
||||
FE-->>B: chat opens (real-time via existing chat join)
|
||||
FE-->>S: chat opens (real-time via existing chat join)
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
|
||||
A->>FE: Admin dashboard, click "Pick up"
|
||||
FE->>BE: POST /api/disputes/{id}/assign
|
||||
Note right of BE: ⚠️ No role guard
|
||||
BE->>DB: dispute.adminId, status="in_progress", timeline.push
|
||||
BE->>DB: chat.participants.push(admin)
|
||||
BE-->>FE: { dispute }
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
|
||||
loop investigation
|
||||
A->>FE: Chat with B & S
|
||||
B-->>BE: POST /api/disputes/{id}/evidence (image)
|
||||
BE->>DB: dispute.evidence.push, timeline.push
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
end
|
||||
|
||||
A->>FE: Click "Resolve" choose action
|
||||
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes }
|
||||
BE->>DB: dispute.status="resolved", resolution={...}
|
||||
FE->>BE: POST /api/disputes/{id}/resolve { action, amount?, notes? }
|
||||
Note right of BE: ⚠️ No role guard (dashboard router)
|
||||
BE->>DB: dispute.status="resolved", resolution={action, amount, currency, notes, ...}
|
||||
alt action="refund"
|
||||
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
|
||||
else action="release"
|
||||
A->>BE: trigger payout to seller\n[[Payout Flow]]
|
||||
else action="partial"
|
||||
A->>BE: split — refund X to buyer, release Y to seller
|
||||
else action="replacement"
|
||||
A->>BE: arrange replacement item (manual)
|
||||
else action="compensation"
|
||||
A->>BE: partial payment to buyer (manual)
|
||||
else action="warning_seller" / "ban_seller"
|
||||
A->>BE: admin account action (manual)
|
||||
else action="no_action"
|
||||
A->>BE: dismiss dispute
|
||||
end
|
||||
BE-->>FE: { dispute }
|
||||
IO-->>B: 'new-notification' dispute resolved (planned)
|
||||
IO-->>S: 'new-notification' dispute resolved (planned)
|
||||
Note over IO: ⚠️ No socket events fire (TODO stubs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/disputes` | `disputeRoutes.ts:12` → `DisputeController.createDispute` |
|
||||
| `GET` | `/api/disputes` | `disputeRoutes.ts:15` (filters: status, priority, category, adminId, buyer/seller) |
|
||||
| `GET` | `/api/disputes/statistics` | `disputeRoutes.ts:18` |
|
||||
| `GET` | `/api/disputes/:id` | `disputeRoutes.ts:21` |
|
||||
| `POST` | `/api/disputes/:id/assign` | `disputeRoutes.ts:24` |
|
||||
| `PATCH` | `/api/disputes/:id/status` | `disputeRoutes.ts:27` |
|
||||
| `POST` | `/api/disputes/:id/resolve` | `disputeRoutes.ts:30` |
|
||||
| `POST` | `/api/disputes/:id/evidence` | `disputeRoutes.ts:33` |
|
||||
### Dashboard router (`backend/src/routes/disputeRoutes.ts`) — mounted first at `/api/disputes`
|
||||
|
||||
All require `authenticateToken` (router-level middleware).
|
||||
| Method | Endpoint | Auth | Role Guard | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `POST` | `/api/disputes` | `authenticateToken` | None | Create dispute |
|
||||
| `GET` | `/api/disputes` | `authenticateToken` | None | List with filters |
|
||||
| `GET` | `/api/disputes/statistics` | `authenticateToken` | None | Aggregate stats |
|
||||
| `GET` | `/api/disputes/:id` | `authenticateToken` | None | Get by ID |
|
||||
| `POST` | `/api/disputes/:id/assign` | `authenticateToken` | **MISSING** ⚠️ | Self-assign possible |
|
||||
| `PATCH` | `/api/disputes/:id/status` | `authenticateToken` | **MISSING** ⚠️ | Any user can change status |
|
||||
| `POST` | `/api/disputes/:id/resolve` | `authenticateToken` | **MISSING** ⚠️ | Any user can resolve |
|
||||
| `POST` | `/api/disputes/:id/evidence` | `authenticateToken` | None | Add evidence |
|
||||
|
||||
### Release-hold router (`backend/src/services/dispute/disputeRoutes.ts`) — mounted second at `/api/disputes`
|
||||
|
||||
| Method | Endpoint | Auth | Role Guard | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `POST` | `/api/disputes/:purchaseRequestId/raise` | `authenticateToken` | Buyer or admin (inline check) | Sets hold fields on PurchaseRequest |
|
||||
| `POST` | `/api/disputes/:purchaseRequestId/resolve` | `authenticateToken` | `authorizeRoles('admin')` ✓ | Clears hold fields |
|
||||
| `GET` | `/api/disputes/:purchaseRequestId/status` | `authenticateToken` | Participant or admin (inline check) | Returns hold/block status |
|
||||
|
||||
> [!warning] Route shadowing: `POST /api/disputes/:id/resolve` in the dashboard router (no guard, mounted first) will intercept requests before they reach the release-hold router's `POST /:purchaseRequestId/resolve` (has guard). See [Route Shadowing](#route-shadowing).
|
||||
|
||||
---
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
|
||||
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
|
||||
- **`purchaserequests`** — not directly mutated by the dispute service; the resolution side-effect (release/refund) updates the request via [[Escrow Flow]].
|
||||
- **`purchaserequests`** — hold fields (`disputeRaised`, `disputeRaisedAt`, `disputeResolved`, `disputeResolvedAt`, `disputeHoldReason`, `holdUntil`) mutated by the release-hold service. Not touched by `DisputeService` directly.
|
||||
- **`payments`** — touched indirectly when the admin performs the financial resolution.
|
||||
- **`notifications`** — `TODO` markers in code; planned addition.
|
||||
- **`notifications`** — TODO; no writes happen today.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`).
|
||||
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
|
||||
> [!warning] None of the following events actually fire. Every emit block in `DisputeService` is commented out as a TODO stub.
|
||||
|
||||
Planned events (not yet implemented):
|
||||
- **`new-notification`** → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, and resolution.
|
||||
- **`dispute-updated`** → planned but not implemented.
|
||||
|
||||
The only real-time activity in the dispute flow today is through the standard **Chat** socket (`new-message` on `chat-{disputeChatId}`) when participants send chat messages — this flows through `ChatService.sendMessage`, which is separate from the dispute service and does emit.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
|
||||
- **Timeline append-only log** is the audit trail. Surface it in the admin UI for compliance.
|
||||
- **Timeline append-only log** is the audit trail. The pre-save hook auto-appends `dispute_created` on insert. Surface this in the admin UI for compliance.
|
||||
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
|
||||
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Purchase request missing** → `400 Purchase request not found`.
|
||||
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer + admin only). Recommended: reject creation in this case to avoid mediator-less situations.
|
||||
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer only, no seller). Recommended: reject creation in this case to avoid mediator-less situations.
|
||||
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
|
||||
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates.
|
||||
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding a unique index on `(purchaseRequestId, status)` filtered to `pending|in_progress` to prevent duplicates.
|
||||
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
|
||||
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin/custody operator completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution.
|
||||
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution action.
|
||||
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
|
||||
- **Route collision** → both routers share `/api/disputes`. See [Route Shadowing](#route-shadowing) for details and recommendation.
|
||||
|
||||
> [!tip] Sort disputes by priority + age
|
||||
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
|
||||
@@ -189,16 +323,17 @@ All require `authenticateToken` (router-level middleware).
|
||||
|
||||
- [[Chat Flow]] — message-level mechanics inside the dispute chat.
|
||||
- [[Escrow Flow]] — the financial state being contested.
|
||||
- [[Payout Flow]] — executed on `release` resolutions.
|
||||
- [[Notification Flow]] — channels for dispute alerts.
|
||||
- [[Payout Flow]] — executed on `refund` / `compensation` resolutions.
|
||||
- [[Notification Flow]] — channels for dispute alerts (not yet wired).
|
||||
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/dispute/DisputeService.ts`
|
||||
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
|
||||
- Backend: `backend/src/routes/disputeRoutes.ts`
|
||||
- Backend: `backend/src/services/dispute/disputeRoutes.ts`
|
||||
- Backend: `backend/src/models/Dispute.ts`
|
||||
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
||||
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)
|
||||
- `backend/src/services/dispute/DisputeService.ts` — core service logic
|
||||
- `backend/src/services/dispute/disputeRoutes.ts` — release-hold router (admin-guarded resolve)
|
||||
- `backend/src/services/dispute/releaseHoldService.ts` — hold field helpers
|
||||
- `backend/src/routes/disputeRoutes.ts` — dashboard/controller router (missing role guards)
|
||||
- `backend/src/models/Dispute.ts` — canonical schema and enums
|
||||
- `backend/src/app.ts` lines 521 and 585 — mount order (shadowing risk)
|
||||
- `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
||||
- `frontend/src/sections/admin/` — admin dispute dashboard (subject to organisation)
|
||||
|
||||
Reference in New Issue
Block a user