docs: sync from backend 8fc2309 — M43/M44 missing FKs + H37 dispute enums
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
# Offer Selection & Rejection — Bug Analysis & Fix (2026-06-06)
|
||||
|
||||
## Symptom (reported)
|
||||
|
||||
In the Telegram Mini App, a buyer created a request, received offers from multiple
|
||||
sellers, accepted one and paid for it. **Both** the winning and losing seller then
|
||||
saw their request stuck at step 4 — «۴. انتظار ارسال کالا» (awaiting shipment) — as
|
||||
if both had won and both needed to ship goods.
|
||||
|
||||
## Investigation — backend vs UI
|
||||
|
||||
We traced the actual request (`کیر خر`, id `54c9de14-…`) directly in Postgres:
|
||||
|
||||
| Offer | Seller | DB status |
|
||||
|-------|--------|-----------|
|
||||
| `e90a099f` | `8346800b` | **accepted** ✅ |
|
||||
| `81b1e7af` | `4ba2a6fe` | **rejected** ✅ |
|
||||
|
||||
- `purchase_requests.selected_offer_id` = `e90a099f` (the winner) ✅
|
||||
- Request status: `delivery` ✅
|
||||
|
||||
**Notifications** (also correct):
|
||||
|
||||
| Recipient | Title |
|
||||
|-----------|-------|
|
||||
| winning seller `8512c583` | `✅ پیشنهاد شما پذیرفته شد!` |
|
||||
| losing seller `a13d3f04` | `❌ پیشنهاد شما رد شد` |
|
||||
|
||||
**Conclusion: the backend was correct.** Offers were properly accepted/rejected and
|
||||
both sellers received the correct notification. The bug was **purely in the Mini App
|
||||
UI**, which derived the seller's step from the *request* status (`delivery`) without
|
||||
checking the seller's *own* offer status.
|
||||
|
||||
## Root cause (UI)
|
||||
|
||||
`telegram-request-detail-view.tsx` computed the seller flow purely from
|
||||
`request.status`:
|
||||
|
||||
```ts
|
||||
const sellerOnDeliveryStep = role === 'seller' && request?.status === 'delivery';
|
||||
const currentStep = determineCurrentStepFromStatus(request.status, role);
|
||||
```
|
||||
|
||||
A rejected seller, whose offer status is `rejected`, still saw the full seller stepper
|
||||
(including step 4 «انتظار ارسال کالا») because the request as a whole is in `delivery`.
|
||||
|
||||
### ID-namespace gotcha
|
||||
|
||||
The fix needs to know whether *this* seller won. Marketplace offers store
|
||||
`sellerId` as the **Postgres UUID** (`users.id`), but the auth user's `_id`/`id`
|
||||
is the **legacy Mongo ObjectId** (`legacy_object_id`). The auth payload exposes
|
||||
`pgId` for the UUID — so the ownership check must compare `offer.sellerId` against
|
||||
`user.pgId`, **not** `user._id`. (Verified via `/api/auth/login` response shape.)
|
||||
|
||||
## Fix (UI) — frontend v2.9.13 (built on mojtaba's v2.9.12)
|
||||
|
||||
The parallel agent (mojtaba) shipped **v2.9.12** first: it added the canonical
|
||||
`StepContext` API in `request-config.tsx` (`determineSellerStep` returns
|
||||
`SELLER_REJECTED_STEP = 0` when `hasSelectedOffer && !isSelectedSeller && hasOffer`
|
||||
and status is post-selection), fixed the **web** seller view, and fixed the telegram
|
||||
stepper's RTL connector lines. **But it did not wire the telegram detail view into
|
||||
that API** — that view still called `determineCurrentStepFromStatus(status, role)`
|
||||
without a ctx and kept an ungated `sellerOnDeliveryStep`, so the mini-app stayed broken.
|
||||
|
||||
**v2.9.13** (this fix) wires the telegram detail view into mojtaba's StepContext:
|
||||
- New `userId` prop carries the user's **pgId** (from `telegram-mini-app-view.tsx`
|
||||
as `selfPgId = user.pgId ?? selfId`).
|
||||
- For sellers in a post-selection status, fetch the offers list. The API only returns
|
||||
non-rejected offers, so a loser's offer is absent — we synthesise
|
||||
`sellerOfferStatus: 'rejected'` when a winning offer exists that isn't this seller's
|
||||
(so `determineSellerStep`'s `hasOffer` guard is satisfied and it returns step 0).
|
||||
- Build `sellerStepCtx = { sellerOfferStatus, isSelectedSeller, hasSelectedOffer }` and
|
||||
pass it to `determineCurrentStepFromStatus(status, role, ctx)` — same logic as web.
|
||||
- `sellerIsRejected = currentStep === 0`; gate `sellerOnDeliveryStep` on `!sellerIsRejected`
|
||||
and render a dedicated «پیشنهاد شما انتخاب نشد» screen.
|
||||
- New locale keys `offer_not_selected_title` / `offer_not_selected_body` (en + fa).
|
||||
|
||||
### Key gotcha — pgId vs legacy _id
|
||||
Offers store `sellerId` as the **Postgres UUID** (`users.id`); the auth user's
|
||||
`_id`/`id` is the **legacy Mongo ObjectId** (`legacy_object_id`). The auth payload
|
||||
exposes `pgId` for the UUID. Ownership checks must compare `offer.sellerId` against
|
||||
`user.pgId`, not `user._id`. Notification `userId`, however, uses the legacy id.
|
||||
|
||||
## Hardening (backend) — v2.9.11
|
||||
|
||||
Although the *select-then-pay* flow (the Mini App path, via `marketplaceController.selectOffer`
|
||||
→ `SellerOfferService.acceptOffer`) already persisted loser notifications, several
|
||||
**direct payment paths** rejected sibling offers at the repo/SQL level **without**
|
||||
sending notifications:
|
||||
|
||||
- `paymentRoutes.ts` `/payments/verify` — called `repo.acceptOffer` (no notify)
|
||||
- `paymentController.ts` payment propagation — called `repo.acceptOffer` (no notify)
|
||||
- `paymentCoordinator.ts` escrow-funded path — raw in-tx reject (no notify)
|
||||
|
||||
### Changes
|
||||
|
||||
1. **`SellerOfferService.acceptOffer` is now idempotent.** It snapshots the
|
||||
pending/active siblings *before* the accept and notifies exactly those freshly
|
||||
rejected. A repeat call rejects 0 rows → notifies nobody. The winner notification
|
||||
only fires when the offer actually transitions to `accepted` (guarded on prior
|
||||
status). This makes it safe to call from every payment path without double-notify.
|
||||
|
||||
2. **`paymentRoutes` & `paymentController`** now call `SellerOfferService.acceptOffer`
|
||||
(with a repo fallback) so winner + losers are notified.
|
||||
|
||||
3. **`paymentCoordinator`** keeps its atomic in-transaction reject (v2.9.10) for the
|
||||
money path, but now captures the freshly-rejected seller ids via `.returning()`
|
||||
and sends the winner/loser notifications **after commit** (best-effort).
|
||||
|
||||
## Regression test
|
||||
|
||||
`backend/scripts/smoke/offer-selection-rejection.mjs` — **21/21 PASS** against dev.
|
||||
|
||||
Flow: 1 buyer + 3 sellers → request → 3 offers → buyer selects offer[0]. Asserts:
|
||||
- buyer sees exactly 1 offer (2 rejected + hidden)
|
||||
- the visible offer is the winner with status `accepted`
|
||||
- each losing seller's offer is hidden (rejected)
|
||||
- **each losing seller received the `❌ پیشنهاد شما رد شد` notification**
|
||||
- **the winning seller received the `✅ پیشنهاد شما پذیرفته شد!` notification**
|
||||
- the request records the winning `selectedOfferId`
|
||||
|
||||
Run:
|
||||
```bash
|
||||
ADMIN_EMAIL=… ADMIN_PASSWORD=… API_BASE_URL=https://dev.amn.gg \
|
||||
node backend/scripts/smoke/offer-selection-rejection.mjs
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
**Frontend (v2.9.13 — pushed)**
|
||||
- `src/sections/telegram/view/telegram-request-detail-view.tsx`
|
||||
- `src/sections/telegram/view/telegram-mini-app-view.tsx`
|
||||
- `src/sections/telegram/locales/{en,fa,types}.ts`
|
||||
|
||||
**Backend (v2.9.11 — pushed; bundled with the v2.9.12 Mongo retirement)**
|
||||
- `src/services/marketplace/SellerOfferService.ts`
|
||||
- `src/services/payment/paymentRoutes.ts`
|
||||
- `src/services/payment/paymentController.ts`
|
||||
- `src/services/payment/paymentCoordinator.ts`
|
||||
- `scripts/smoke/offer-selection-rejection.mjs`
|
||||
|
||||
Push was initially held while the parallel Mongo-retirement refactor (which broke
|
||||
the shared working tree's typecheck) was in flight. Once it compiled clean, the
|
||||
nuke + v2.9.11 were committed (`30a88eb` v2.9.12, `15bbae3` v2.9.11) and pushed.
|
||||
Reference in New Issue
Block a user