145 lines
6.9 KiB
Markdown
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.
|