Files
nick-doc/11 - Testing/Offer Selection & Rejection — Bug Analysis 2026-06-06.md

145 lines
6.9 KiB
Markdown

# 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.