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:
Siavash Sameni
2026-05-29 14:47:49 +04:00
parent 5113b0df23
commit a1f056e6a5
47 changed files with 2160 additions and 196 deletions

View File

@@ -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)