docs: add session log for marketplace fixes and eBay-style counter-offer implementation
This commit is contained in:
121
nickdock/2026-06-08-counter-offer-and-marketplace-fixes.md
Normal file
121
nickdock/2026-06-08-counter-offer-and-marketplace-fixes.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 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_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.sql` — **hand-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.ts` —
|
||||
`initiate/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/version` → `2.10.x`.
|
||||
2. Apply the migration:
|
||||
```bash
|
||||
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: cron` → `event: 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.
|
||||
Reference in New Issue
Block a user