7.6 KiB
Session log — 2026-06-08 — Marketplace fixes + eBay-style counter-offer
Compaction/handoff doc for the work done in this session on the AMN escrow
marketplace (~/code/backend, ~/code/frontend, ~/code/docs). Versions land
on dev via Woodpecker CI (frontend auto-deploys; backend does NOT — see
§Deploy).
1. Bug fixes (all shipped)
All rooted in the Mongo→Postgres id seam: user identity in JWTs is the legacy
24-char ObjectId, while PG entities reference users by uuid (user.pgId); and
offers/entities are keyed on id (uuid), not _id.
| Bug | Root cause | Fix |
|---|---|---|
| Seller "تایید ارسال کالا" → 403 + winner/loser stepper swapped | seller-request-details-view.tsx computed isSelectedSeller from sellerOffer._id but offers come keyed on id → always undefined → winner front-stopped to "rejected", loser fell through to "ship goods" |
resolve offer id via id ‖ _id on both sides of the selected-offer compare |
| Cancel/Edit offer never worked ("یافت نشد" / 400 "already have an offer") | matched offer.sellerId (uuid) against sellerId (=user._id ObjectId) + read offer._id |
use backend-filtered getSellerOfferForRequest (bridges the seam server-side) + id ‖ _id, in step-1 (load+update-vs-create) and step-2 (withdraw) |
| Losing seller saw "ship goods" instead of "offer rejected" | offers endpoint filtered out rejected/withdrawn even for the seller's OWN offer → loser's rejected offer unretrievable → front-stop never fired |
getOffersByPurchaseRequest(includeInactive); controller passes true when ?sellerId= present |
| Lost request vanished from loser's dashboard list | findPurchaseRequests seller-visibility kept post-selection requests only for the winner |
also keep when the seller has a non-withdrawn offer (the loser) |
| Edit offer → 404 | frontend PATCH /marketplace/offers/:id but no such route existed (only create/delete/status) |
added PATCH /offers/:id → updateSellerOffer controller (seller-or-admin auth) |
Backend selectOffer 403 itself was correct (a non-winning seller can't ship);
the bug was the frontend showing them the ship step.
2. Feature — eBay-style multi-round counter-offer (negotiation)
Decided rules: buyer initiates on a seller's offer; either side
counter/accept/reject; negotiable = price + delivery time + message; cap 5
rounds; 48h per-counter expiry (lazy — no scheduler); on accept the
agreed terms are written onto the offer, then the existing accept/select flow
runs (siblings rejected, selectedOfferId set, buyer pays the agreed price).
Backend
- Schema:
src/db/schema/offerNegotiation.ts—offer_negotiationstable (append-only, one row per round) + enumsnegotiation_proposed_by,negotiation_status(pending/accepted/rejected/countered/expired). Registered inschema/index.ts. - Migration:
src/db/migrations/0023_offer_negotiations.sql— hand-written & idempotent (the drizzle-kit journal is stale — do NOTdb:generate, it wants to recreate every existing table). Must be applied manually (see §Deploy). - Repo (
IMarketplaceRepo+DrizzleMarketplaceRepo):findNegotiationsByOffer,findLatestNegotiationByOffer,createNegotiationRound,counterNegotiation(transactional row-lock +unique(offer_id,round_number)backstop),resolveNegotiation. - Service:
src/services/marketplace/NegotiationService.ts—initiate/counter/accept/reject/getThread,MAX_NEGOTIATION_ROUNDS=5,NEGOTIATION_EXPIRY_MS=48h, lazy-expiry helper. Identity taken frompr.buyerId/offer.sellerId(uuids) to avoid the id seam.acceptwrites terms viaupdateSellerOffer, delegates toSellerOfferService.acceptOffer, then mirrorsselectOffer's tail (setselectedOfferId+ emitoffer_selected; status NOT forced to payment — payment flow advances it). - Controller + routes (
marketplaceController.ts,controllerRoutes.ts): 5 routes under/api/marketplace/offers/:offerId/negotiations(GETthread,POSTinitiate,/counter,/accept,/reject),sameUserauth.selectOffernow blocks paying while a counter is pending (wrapped defensively so a not-yet-migrated table can't break the core flow).
Frontend
- Actions/endpoints:
actions/marketplace.ts(getOfferNegotiations,initiate/counter/accept/rejectNegotiation+INegotiationRound/Threadtypes),lib/axios.ts(marketplace.negotiations). - Components:
src/sections/request/components/negotiation/—request-negotiation-{form,thread,actions,dialog}.tsx+ barrel. - Wiring: web buyer offer card (
buyer-steps/.../step-2-offers.tsx) + sellerstep-2-waiting-for-payment.tsx(respond panel whenin_negotiation) + pay disabled while pending + realtime (use-unified-real-time.tshandlesnegotiation-*/offer_selected). Mini-app:telegram/view/telegram-request-detail-view.tsxoffer card got the haggle button too (opens the same dialog). in_negotiationstatus label/color + stepper mapping already existed — no new step; negotiation is an overlay on the offers/waiting steps.
3. Deploy (CRITICAL — why it "doesn't work" yet)
- Frontend auto-deploys on push (currently dev shows v2.10.x).
- Backend does NOT auto-deploy.
.woodpecker/development.ymlis gated onwhen: event: cronwith no cron configured → never fires on push.manual.ymlonly builds+pushes the image (no redeploy). Running backend is stuck at v2.8.96 → negotiation routes return 404 → the frontend dialog shows "امکان مذاکره وجود ندارد" everywhere. - To go live (must be done on the server/dashboard — no code access from here):
- Redeploy
escrow-backendfrom latestmain(Arcane dashboard → service → Redeploy/Rebuild). Verify/api/version→2.10.x. - Apply the migration:
docker exec -i amanat-postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ < src/db/migrations/0023_offer_negotiations.sql
- Redeploy
- Optional permanent fix (needs sign-off — team disabled it for docker disk
pressure): repoint
development.ymlevent: cron→event: pushand build togit.tbs.amn.gg/escrow/backendso backend auto-deploys like frontend.
4. Outstanding / known pre-existing issues (NOT from this session)
- "نامشخص" (category) / "انتخاب نشده / نامشخص" (seller name) across lists & offer
cards: Mongo→PG population gap — APIs return
categoryId/selectedOfferId/sellerIdas raw uuids, but the frontend still expects Mongo's populated objects (categoryId.name,selectedOfferId.sellerId.firstName). Fix = backend populate names (join users/categories) and/or frontend resolve (category is fixable frontend-only via the already-loadedgetCategories()). Not a regression. getUserChatsDrizzleQueryError (42P18"could not determine data type of parameter $2") — thejsonb_path_exists(... jsonb_build_object('uid',$2,...))query needs a$2::textcast. Breaks the chat list. Separate bug.escrow-mongodbcontainer was Exited earlier (auth/data are on PG, so unrelated to the 403s); later the service list dropped mongo entirely.
5. Verify end-to-end (after backend deploy + migration)
Buyer create request → seller offer → buyer "چونهزدن" (price/delivery) [status →
in_negotiation] → seller counter → buyer accept → offer updated to agreed terms,
siblings rejected, selectedOfferId set, buyer pays the agreed price. Negatives:
counter at round 5 → 400; accept on expired → 400; select-offer while pending → 409.