Offer Cart (CAP-4002)
Plan, not built · builds on branch CAP-4002-dev · backendApi, capExpertApp, dbMigrations · 16 Oct 2026
Status: plan agreed, nothing built yet. Vendors (and admins, for a vendor) collect listings in a cart grouped by facility, then send one offer per facility through the existing grouped-offer dialog and send-offer API. Offered items leave the cart in the same transaction.
Four points are left to confirm before building: the per-vendor cart limit, the grouped-offer limit (the API says 200, the front end says 50), the clean-up window, and the 1M-row timings, which are estimates until measured.
marketplace_offer_cart_items; no parent table; the purchase cart is untouchedOn this page
- Decisions
- How it works
- Why a new table, and no parent
- Database
- API
- The cart page
- Other front-end changes
- Edge cases
- Scale: 1k vendors, 1M rows
- Build order
- Checks
- Still to confirm
Decisions
| Topic | Decision | Source |
|---|---|---|
| Sending from the cart | Per facility. Each facility card sends one grouped offer, for all its items or the ticked ones. A single item goes out as an individual offer. | Agreed |
| Browse Market multiselect | The bulk bar's "Send offer" becomes "Add to cart". The cart is where multi-item offers are sent from. | Agreed |
| Who owns a cart | The vendor company, shared by all its users. | Agreed |
| Admins | Included. An admin picks a vendor and works on that vendor's cart, so items an admin adds show in the vendor's cart. | Agreed |
| Storage | One new table, no parent table. The facility purchase cart (carts / cart_items) is not reused. | Agreed |
| Cart page | Same layout as the current purchase cart: vendor header (admins only), one card per facility, item rows, "Sold by" footer. | Agreed |
| FMV | Vendors never receive it. The cart API leaves it out of vendor responses; admins see it. | Agreed |
| My Listing bulk "Send offer" | Also becomes "Add to cart", so there is one way to send multi-item offers. | Default |
| Detail view | Keeps its direct Send Offer for one item and gains Add to Cart. A direct send also removes the item from the cart. | Default |
How it works
Vendor
- Opens a listing and clicks Add to Cart, or ticks several on Browse Market and clicks Add to cart.
- Opens the cart from the header cart icon: one card per facility, each with its items.
- On a card, sends for all items or just the ticked ones. The existing offer dialog asks for one price.
- The offer is created, and those items leave the cart in the same transaction.
Admin
- Picks the vendor when adding (the same vendor picker as Send Offer).
- Opens the Offer Cart tab in the marketplace tabs, and selects a vendor before anything loads.
- Sends from a card exactly as a vendor does. The offer goes out as that vendor, with no second vendor pick.
Why a new table, and no parent
Not the purchase cart's cart_items
The purchase cart fits structurally (one carts row per buyer and seller), but the code treats every cart_items row as a facility reserving a listing to buy it. A vendor's cart row would be read the same way:
| Where | What would go wrong |
|---|---|
MarketPlace.cartItem is @HasOne | The model allows one cart row per listing. Five vendors can cart the same listing, so listing reads would pick one at random. |
notInCartSql; front-end takesOffer and sendOfferDisabled | Any cart row blocks offers. One vendor's cart would stop every other vendor from offering on that listing. |
| The purchase cart's "already in another cart" check | Facility buyers would be blocked by a vendor's cart. |
| Divest→auction job, two cron jobs, sales dashboard, header count | All read cart rows as purchases, so they would make wrong auction decisions and inflate counts. |
A type column would need a filter in every one of the ~40 API files that read these tables, and missing one fails silently. Reuse also gains nothing: everything on the carts row (shipping quote, PO, bill of sale, self-shipping) is checkout state an offer never uses.
No parent table
- No cart-level state. The price is typed into the dialog at send time and saved on the offer.
- The grouping can always be worked out from existing data. The vendor is
vendor_id, and the facility is the listing'saccount_id, joined when the cart is read, so it never goes out of date. - Once sent, a parent already exists: the grouped offer's thread (
marketplace_offer_associations) with itsmarketplace_offer_items.
Add a parent only if something per cart or per facility needs storing, such as a draft price, several named carts per vendor, or a cart status. That is an additive migration later.
Database (dbMigrations), one migration
CREATE TABLE marketplace_offer_cart_items (
id SERIAL PRIMARY KEY,
vendor_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
marketplace_id INTEGER NOT NULL REFERENCES marketplace(id) ON DELETE CASCADE,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_marketplace_offer_cart_items_vendor_listing UNIQUE (vendor_id, marketplace_id)
);
CREATE INDEX idx_marketplace_offer_cart_items_marketplace_id ON marketplace_offer_cart_items (marketplace_id);
- The unique key stops duplicates, and serves every per-vendor query because
vendor_idis its first column. - The
marketplace_idindex keeps listing deletes and the clean-up job fast on a large table. - The migration's
downdrops the table. The file name uses the real current timestamp.
API (backendApi, primary role)
New files
shared/entity/marketplace-offer-cart-item.entity.ts, linked to its listing (marketPlaceData), its vendor (vendorDetails) and the user who added it (createdByUser). It is registered in the entity index,database.providers.tsandproviders.constant.ts, asMarketplaceOfferItemswas.primary/modules/vendor-offers/offer-cart/: controller, service, DTOs, and the cart SQL with a spec. Registered invendor-offers.module.ts.- The controller has its own
offer-cartroute prefix, likegrouped-offers(undervendor-offer/, the/:marketplaceIdroute would capture these paths), with the sameFeatureGuardand@RequireFeature(MARKETPLACE.ROOT).
Limits
| Limit | Value |
|---|---|
| Items per facility | The grouped-offer limit, so a full card can always be sent as one offer. The API says 200, the front end says 50; see Still to confirm. |
| Items per vendor | 500 (suggested) |
| Ids per add or remove request | 200 (@ArrayMaxSize) |
Whose cart a request touches
One helper, with the same rule as Browse Market's offerVendorId():
- a vendor always gets its own account, whatever
vendorIdit sends; - an admin must name the vendor;
- anyone else gets 403.
Endpoints
| Endpoint | Behaviour |
|---|---|
POST /offer-cart{ vendorId?, marketplaceIds[] } | One INSERT … SELECT. A listing is added only if it passes offerableListingSql and notInCartSql and isn't already in the cart. The new items count toward both limits, per facility and per vendor. ON CONFLICT DO NOTHING drops duplicates, and a per-vendor advisory lock stops two adds overshooting a limit. Returns { added, skipped }. |
DELETE /offer-cart?vendorId&marketplaceIds=1,2,3 or &facilityId= | Removes one item, the ticked ones, or a whole facility's items ("Remove All Items"). The ids go in the query string because some clients drop a DELETE body. |
GET /offer-cart?vendorIds&facilityIds&offset&limit | A vendor gets its own cart; an admin gets nothing until a vendor is selected. Returns { count, rows: [{ vendor, carts: [{ facility, itemCount, offerableCount, items[] }] }] }, the same nesting as the purchase cart. |
How GET builds a page (always limited to the selected vendors)
- One page of facility cards. Each card is one vendor and facility pair, ordered by vendor name then facility name. The grouping and counting run in SQL, and
countis the number of cards. - The items on those cards only. Each item has its listing and inventory (through the existing
getInventoryInclude), the samechildCountthe purchase cart computes, and anofferableflag.fairMarketValueis included only whencheckNotVendorAndVendorLevel(reqUser)passes, the same check as Browse Market's list.offerableCountis computed here, for these cards only. - The vendor and facility accounts on the page, through
getAccountInclude: name, default address, primary user and contact.
The service then nests the rows into that shape.
Changes to existing code
| Where | Change |
|---|---|
vendor-offers.service.ts: sendOffer and sendGroupedOffer | Inside the existing transaction, delete that vendor's cart rows for the offered listings. This covers every way an offer is sent, and if the send fails, the delete is rolled back with it. |
notification.service.ts: cartItemsCount | Returns the vendor's offer-cart count instead of 0. This drives the header badge, so no new count plumbing is needed. |
marketplace.base.query.ts, marketplace-finder.service.ts | Add a vendor-only inOfferCart flag, an EXISTS lookup on the unique key, beside the existing cartItem include. |
An existing daily job in import/modules/cron/ | Delete cart rows whose listing has been deleted, or off sale, for 30 or more days. Nothing in the repo schedules itself, so this goes into a job that already runs. |
The cart page: marketplace/offer-cart
It lives at modules/market-place/offer-cart/. Each area maps to the current purchase cart (modules/cart):
| Area | Current purchase cart | Offer cart |
|---|---|---|
| Filters | Group / Region / Facility (non-facility users) | Admins only: Vendor and Facility (app-async-filter; both types exist). Until a vendor is chosen, the page shows "Please select a vendor to view carts", like the purchase cart's "select facility" message. |
| Results line | "Showing results X–Y of N" | Same; N counts facility cards. |
| Account header | Buyer: name, city/state, email and phone with copy | Vendor, admins only, sticky. A vendor sees only its own cart, so it has no header. |
| Card status and stepper | Shipping, PO and quote notices | None |
| Card top | — | "Select all (N)" checkbox |
| Item row, left | Photo with a Divestiture or Auction badge, model number, condition, Multi, model, category, capex id, manufacturer, SN, useful life, manufacture date, quantity, components | Identical, plus a checkbox, disabled when the item can't take an offer |
| Item row, right | "Removed from marketplace", FMV price, remove, Request FMV | "No longer available" when it can't take an offer, FMV for admins only, remove; no Request FMV |
| Summary panel | Items, subtotal, tax, shipping, total, PO and shipping actions | Items · Selected · Available for offer, and the primary button "Send offer · all N" or "Send offer · K selected", with the note "One price covers every item sent together" |
| Card footer | "Sold by" facility and contact; Continue Shopping; Remove All Items | Same "Sold by" block; Continue Browsing to Browse Market; Remove All Items asks for confirmation |
| Pagination | app-table-paginator, 5/10/25 | Same |
| Empty state | "No items in the cart!" with a link to the Internal Marketplace | Same, with Go to Browse Market |
Dark mode and phone widths, like the existing page.
Shared pieces, so it really matches
- Two display blocks move out of
cart.component.htmlintocommon/components/cart/, and the purchase cart uses them with identical output:app-cart-item-details: the left side of an item row.app-cart-party: name, city/state, contact name, email and phone with copy buttons.
- The thumbnail helper, the components modal,
app-button, the shared checkbox,cs-filter-toolbarand the confirmation dialog are reused as they are.
The trade-off: the purchase cart's template changes structure but not output. The alternative is copying about 300 lines of markup that would drift apart.
Sending from a card
- The cart page plugs into
MarketplaceGroupedOffer(GroupedOfferHost), as Browse Market and My Listing already do, and callsmakeGroupedOfferFor(rows). It gets the same address check, the same dialog and the samesend-offercall. MarketplaceGroupedOffergains two small options:- a fixed vendor, taken from the card, so an admin isn't asked to pick again and the address check runs for that vendor;
- one item allowed, which goes out as an individual offer. The API already does this for one id.
- After a send, the page reloads and the header count refreshes.
Other front-end changes
| Where | Change |
|---|---|
offer-cart-actions.ts (new, small) | One "add to offer cart" helper used by the detail view, Browse Market and My Listing. It handles the admin's vendor pick (VendorSelectDialogComponent), the added/skipped message, and the header count refresh. |
Detail view (details-modal) | Add to Cart next to Send Offer, shown only where Send Offer would be usable. A vendor toggles Add/Remove using inOfferCart; an admin picks the vendor, then adds. |
Browse Market (market-place/list) | The bulk button becomes "Add to cart", and the single-facility restriction is dropped. "Select all N listings" still needs one facility selected, and adds up to the per-facility limit. |
My Listing (listing/list) | The bulk "Send offer" becomes "Add to cart". |
| Header | The cart icon, hidden for vendors today, shows for vendors with the offer-cart count and links to the page. |
| Marketplace tabs | An "Offer Cart" tab for admins, since their header icon already opens the purchase cart. |
| Routes, messages, client | URL.OFFER_CART; a child route under MarketPlaceLayoutComponent with the same feature check as the Offers tab; the new messages in messages.ts; regenerate swagger and restart the front end. |
Edge cases
| Case | Behaviour |
|---|---|
| A listing is sold, in a PA, auctioned, in a facility's purchase cart, or deleted (soft delete) | Shows as "No longer available" and can't be ticked. The vendor can remove it; the daily job deletes it after 30 days. |
| The vendor offers on a carted listing from another screen | Removed from the cart in that send's transaction. |
| Two users of the same vendor add the same item | The unique key ignores the second add. |
| A facility or the vendor is at its limit | The extra items come back as skipped, with a message. |
| An admin hasn't selected a vendor | Nothing is queried; the page asks for a vendor. |
| A listing changes between adding and sending | The send API already re-checks every listing under row locks. |
Scale: 1k vendors, 1M rows
Every query is scoped to the selected vendor through the unique key's first column, so a vendor's speed doesn't depend on the table's total size. With the plan's fixes in place:
| Operation | What it reads | Expected at 1M rows |
|---|---|---|
| Storage | 1M small rows plus two indexes | About 70–100 MB |
| Header badge count, on every notifications poll | Index-only count of one vendor's rows (500 at most) | About a millisecond |
inOfferCart on listing rows | One unique-key lookup per row on screen | Fine at any size |
| Add / remove / remove-after-send | The ids sent, plus that vendor's rows for the limits | Fine |
| Cart page (vendor, or admin with a vendor selected) | That vendor's rows, and an offerable check on the cards shown | Tens of ms |
| Hard-deleting a listing | The marketplace_id index | An index lookup, not a full scan |
| Rows for gone listings | Deleted after 30 days | The table stays within the vendor limits |
Without the fixes, an admin opening the page with no vendor selected would group all 1M rows and run the offerable check a million times, taking seconds per load. Listing deletes would also scan the whole table. These figures are estimates from the query shapes; Checks lists how to measure them.
Build order (the app works after every step)
- Migration, entity and the three endpoints, plus a spec for the cart SQL.
- Remove-after-send,
cartItemsCount,inOfferCart, and the clean-up job. - Regenerate swagger and restart the front end.
- Move
app-cart-item-detailsandapp-cart-partyout, and switch the purchase cart to them with no visible change. - The offer cart page, including sending from a card.
offer-cart-actions.tsand Add to Cart in the detail view.- Browse Market and My Listing: the bulk button becomes Add to cart.
- The vendor header icon and the admin Offer Cart tab.
Commits go on CAP-4002-dev: one in dbMigrations, and one or two each in backendApi and capExpertApp.
Checks
- Run the migration up and down on the local database.
- API: typecheck,
lint:layering, and the specs. - Swagger before and after, per role: the only additions are the three
offer-cartroutes andinOfferCart, and nothing is removed. - Front end:
ng buildwith the raised heap. No browser. - Scale: insert 1M synthetic rows in a transaction on the local database, run
EXPLAIN ANALYZEon the count, add, both page reads and a listing delete, then roll back. This needs database access. - Confirm that
cart_items.marketplace_idhas an index. The offerable check looks it up for every item, and no migration was found that creates one.
Still to confirm
| # | Point | Suggested |
|---|---|---|
| 1 | How many items one vendor's cart may hold | Decision 500 |
| 2 | The grouped-offer limit doesn't match: the API allows 200 listings (bundle-listings.ts), the front end blocks above 50 (list.models.ts), and that constant's comment says it mirrors the backend. The cart's per-facility limit follows whichever is right. | Decision pick one number and use it in both places |
| 3 | How long rows for gone listings stay before the daily clean-up deletes them | Decision 30 days |
| 4 | Measure the 1M-row figures before relying on them | Before merge needs database access |