Files
nick-doc/context/2026-06-08-counter-offer-and-marketplace-fixes.md

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/:idupdateSellerOffer 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.tsoffer_negotiations table (append-only, one row per round) + enums negotiation_proposed_by, negotiation_status (pending/accepted/rejected/countered/expired). Registered in schema/index.ts.
  • Migration: src/db/migrations/0023_offer_negotiations.sqlhand-written & idempotent (the drizzle-kit journal is stale — do NOT db: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.tsinitiate/counter/accept/reject/getThread, MAX_NEGOTIATION_ROUNDS=5, NEGOTIATION_EXPIRY_MS=48h, lazy-expiry helper. Identity taken from pr.buyerId / offer.sellerId (uuids) to avoid the id seam. accept writes terms via updateSellerOffer, delegates to SellerOfferService.acceptOffer, then mirrors selectOffer's tail (set selectedOfferId + emit offer_selected; status NOT forced to payment — payment flow advances it).
  • Controller + routes (marketplaceController.ts, controllerRoutes.ts): 5 routes under /api/marketplace/offers/:offerId/negotiations (GET thread, POST initiate, /counter, /accept, /reject), sameUser auth. selectOffer now 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/Thread types), 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) + seller step-2-waiting-for-payment.tsx (respond panel when in_negotiation) + pay disabled while pending + realtime (use-unified-real-time.ts handles negotiation-* / offer_selected). Mini-app: telegram/view/telegram-request-detail-view.tsx offer card got the haggle button too (opens the same dialog).
  • in_negotiation status 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.yml is gated on when: event: cron with no cron configured → never fires on push. manual.yml only 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):
    1. Redeploy escrow-backend from latest main (Arcane dashboard → service → Redeploy/Rebuild). Verify /api/version2.10.x.
    2. Apply the migration:
      docker exec -i amanat-postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
        < src/db/migrations/0023_offer_negotiations.sql
      
  • Optional permanent fix (needs sign-off — team disabled it for docker disk pressure): repoint development.yml event: cronevent: push and build to git.tbs.amn.gg/escrow/backend so 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/ sellerId as 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-loaded getCategories()). Not a regression.
  • getUserChats DrizzleQueryError (42P18 "could not determine data type of parameter $2") — the jsonb_path_exists(... jsonb_build_object('uid',$2,...)) query needs a $2::text cast. Breaks the chat list. Separate bug.
  • escrow-mongodb container 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.