6.9 KiB
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:
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
userIdprop carries the user's pgId (fromtelegram-mini-app-view.tsxasselfPgId = 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 (sodetermineSellerStep'shasOfferguard is satisfied and it returns step 0). - Build
sellerStepCtx = { sellerOfferStatus, isSelectedSeller, hasSelectedOffer }and pass it todetermineCurrentStepFromStatus(status, role, ctx)— same logic as web. sellerIsRejected = currentStep === 0; gatesellerOnDeliveryStepon!sellerIsRejectedand 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— calledrepo.acceptOffer(no notify)paymentController.tspayment propagation — calledrepo.acceptOffer(no notify)paymentCoordinator.tsescrow-funded path — raw in-tx reject (no notify)
Changes
-
SellerOfferService.acceptOfferis 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 toaccepted(guarded on prior status). This makes it safe to call from every payment path without double-notify. -
paymentRoutes&paymentControllernow callSellerOfferService.acceptOffer(with a repo fallback) so winner + losers are notified. -
paymentCoordinatorkeeps 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:
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.tsxsrc/sections/telegram/view/telegram-mini-app-view.tsxsrc/sections/telegram/locales/{en,fa,types}.ts
Backend (v2.9.11 — pushed; bundled with the v2.9.12 Mongo retirement)
src/services/marketplace/SellerOfferService.tssrc/services/payment/paymentRoutes.tssrc/services/payment/paymentController.tssrc/services/payment/paymentCoordinator.tsscripts/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.