Platform operators get a points catalog screen (create, edit, publish and
unpublish products with localized names, points price, stock, and ordering) and
a redemption screen that lists orders and moves pending ones to fulfilled or
cancelled. Both are reachable from the console navigation.
The points page loads the published catalog and the customer's live points
through the shared client, redeems with a chosen saved address, refreshes the
balance, and lists the customer's redemption history with its fulfilment state.
The INTEGRAL_PRODUCTS and USER_STATS imports leave the page; the fixed-data
adapter still serves the points domain as the rollback path.
Platform-owned points products and a redemption order lifecycle kept separate
from cash orders. Redeeming locks the published product, reserves stock, creates
the order, debits points through the archived customer-accounts ledger with the
order as reference, and snapshots the line in one transaction; a failure leaves
no order, no stock change, and no ledger entry. Fulfilment moves only from
pending_fulfillment, and customers see only their own redemptions.
Demo seeding credits the demo customer through the same guarded credit path
with reason seed, once, so no balance is ever written absolutely.
Surfaces (admin console, mall points page) and product seeding follow.
Add a coupons page where a shop user lists, creates, edits, enables/disables,
and deletes only its own templates, with localized titles, minor-unit discount
and threshold, currency, claim stock, and an active window. Expose it as a
coupons navigation entry beside the existing shop operations.
The product page loads a shop's claimable coupons through the shared client and
claims them in place; the buyer coupon list reads owned snapshots with their
claimed/redeemed/expired state. Checkout offers each shop order at most one
owned coupon and submits only the choice; the pay page and order detail render
the server-persisted discount and reduced total, never a client figure.
Coupon fixtures leave the pages; the fixed-data adapter still serves the coupon
domain as the rollback path. The demo seed creates two deterministic templates
and claims them for the demo customer.
Templates belong to a shop; claiming copies their terms into a customer-owned
snapshot so a later edit or disable cannot rewrite a held coupon. Claim stock
is taken with a guarded decrement after locking the template, and a unique
(user, template) index makes a duplicate claim a 409. Deleting a template
leaves claimed snapshots standing via ON DELETE SET NULL.
Checkout accepts at most one owned coupon per generated shop order, locks the
selected coupons by primary key after the SKU locks, and resolves eligibility
and the discount server-side (ownership, shop, status, window, converted
threshold). The realized discount and coupon id land on the order, and a
pending-payment cancellation restores the coupon in the same transaction as
stock.
The shared contract gains the coupon types, claim/list/manage methods, and the
checkout coupon map; the fixed-data adapter implements the same surface.
Surfaces (shop-admin management, mall coupon pages, checkout selection) and
seeding still follow in tasks 3.1-4.2.
One balance row per (user, kind, currency): available and frozen carry the
platform base currency, points carries none. Monetary and points rows use
separate partial unique indexes because a plain UNIQUE lets NULL repeat.
Balance changes go through transactional primitives that debits guard with a
conditional update, credits add atomically, and freeze/release move both sides
in one transaction after locking rows by primary key. Every change appends an
immutable entry holding its resulting balance. Registration and a migration
backfill create the zero rows; GET /api/me/stats is the only public surface and
no endpoint mutates a balance.
The mall buyer center and points page drop the USER_STATS fixture for the
shared contract; the fixture stays exported so the fixed-data adapter can still
serve the account domain as a rollback path.
Implements openspec change add-customer-accounts.
Prevent oversell by conditioning stock updates and locking SKUs in primary-key order, and record the same concurrent-counter rule in the API spec and agent guide.
Co-authored-by: Cursor <cursoragent@cursor.com>
Live addresses domain for the mall: shared contract (AddressBookEntry +
five client methods), mock adapter state v3, live domain pick, addresses
page CRUD, checkout saved-address picker with manual fallback, demo seed,
and the archived change plus address-book capability spec.
Keep the REST contract; move domain logic out of route files so checkout, fulfillment, and identity can be reused across customer, shop, and admin surfaces.
Co-authored-by: Cursor <cursoragent@cursor.com>
Wave 6, the last substantive piece of the mock-API migration. Wave 1 removed the
brand facet and the sales/comments sorts for want of a model; sales turn out to
be derivable from order_items and a brand model is a table plus a column.
- a `brands` table with a nullable `products.brand_id` and an ordered admin
replace, mirroring categories and storefront content; a public `GET /api/brands`
and a `brand_id` filter on the catalog, which the search page's facet uses
- `sold_count` per product, computed from `order_items` joined to orders that
reached payment, so an abandoned or cancelled checkout cannot count as a sale.
It is computed per read rather than stored, so it cannot drift from the orders
that produced it
- `sort=sales` alongside `sort=price`; anything else is still a 400
- merchants can set a product's brand through the existing product upsert
- the review UI is gone: the card's review figure and the product detail page's
reviews tab, summary and replies. There is no reviews model, and the mall
attributed invented comments to named shoppers and showed a "good rate". The
now-unreferenced fabrication helpers went with it (`salesOf`, `commentCountOf`,
`commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail`)
Two bugs found by checking rather than trusting: the fixed-data `listProducts`
had silently ignored `brand_id`, `sort` and `order`, so the restored facet
rendered but filtered nothing until the rollback check caught it; and the seed's
brand lookup read back through the shared `r` variable the product loop
reassigns, working once and then throwing.
Verified: 29 backend tests green including a new brand-and-sales case; all three
frontends build; searching filters by brand (24 to 6) and sorts by sales with
counts matching the API; a product page offers detail and after-sale tabs only,
with a real sold count; the fixed-data rollback filters by brand too.
OpenSpec change: openspec/changes/replace-mock-api-wave-6
Wave 5: the store directory, store home and product-page store card stop reading
MOCK_STORES, and the payment and order surfaces name their shop.
- a `shop_profiles` table beside `shops`, so the identity model both consoles
consume is untouched, with a public `GET /api/shops` and `GET /api/shops/{slug}`
and an admin `PUT /api/admin/shops/{id}/profile`
- a shop with no profile is still listed, with the fields absent rather than
invented; the pages guard every block, and a missing logo renders an
initial-letter placeholder
- `scripts/seed-demo.mjs` upserts a profile per demo shop, since profiles hang
off shops that script creates
- payment and order pages resolve shop ids to names from one cached shop read,
retiring the generic "Shop" label
- three things went rather than being faked, following the wave-1 precedent:
`distanceKm` and its sort (no geo model), the store home's sales/comments
sorts, and its "best sellers" rail (no sales model)
- `lowestSku` moved out of the fixed-data module into `apps/mall/utils/product.ts`
and re-exported, so live pages stop importing the mock module for a pure
helper
Verified: 28 backend tests green including five new shop tests; all three
frontends build; the directory, store home, store card and order cards all render
real data with no distance or sales claims; the fixed-data rollback still renders
the store surfaces with the backend stopped.
Note: `nuxt build` does not typecheck in this repo (no `typescript.typeCheck`,
no `vue-tsc`), which AGENTS.md implies it does. A re-export used here created no
local binding and broke internal callers at runtime while the build stayed green;
`docs/TBD-migrate-wave.md` records the gap.
OpenSpec change: openspec/changes/replace-mock-api-wave-5
Wave 4 of replacing the fixed-data mock adapter, and the first capability the
mall never had a backend for: the home page's banners, promo tiles, quick links
and floor advert art move out of local arrays.
- four explicit tables (`banners`, `promos`, `quick_links`, `floor_adverts`)
rather than one JSONB payload table, so Postgres enforces each shape
- a migration seeds them from the assets the page already rendered, so the flip
is visually a no-op. Destinations are real routes now: the mock's promo links
pointed at dangling `?category=c1` ids and its first banner used `sort=sales`,
which the catalog API rejects
- `GET /api/content/home` is public and returns the four active, ordered lists,
always including a key so a page can render a missing block
- `GET /api/admin/content` and `PUT /api/admin/content/{kind}` let a platform
admin read everything and replace one kind transactionally, with positions
reindexed from the submitted order and a rejected list changing nothing
- the mall's fixed-data adapter learns `getHomeContent`, and a `content` domain
joins the per-domain switch so the rollback path still renders the page
Verified: 23 backend tests green including six new content tests; all three
frontends build; the home page renders the same four blocks as before, an admin
reorder and deactivation change the rendered carousel, and the fixed-data
rollback renders every block with the backend stopped.
OpenSpec change: openspec/changes/replace-mock-api-wave-4
Wave 3 of replacing the fixed-data mock adapter: cart, orders, shipments and
invoices flip together, so one purchase runs end to end against the backend.
- cart: CartItemView carries the line's shop and the SKU's stock, so the cart
keeps grouping per shop and the quantity stepper caps at real stock instead
of a hard-coded 999
- contract: Shipment.items is optional and Invoice.invoice_no nullable, both
matching what the API actually returns. Invoice was declared twice in
types.ts and TypeScript merges duplicate interfaces, so the duplicate had to
go for the change to take effect at all
- an anonymous add-to-cart redirects to /login?redirect=..., and sign-in
honours only same-origin paths
- the fixed-data adapter learns the new cart fields, and its persisted state
key moves to v2 because a cart saved by an older build is no longer valid
- order surfaces drop their storeById lookups and keep the generic store label
until the public store read arrives
Verified end to end: two-shop cart grouping with live shop names, stock caps
read from the API, checkout, payment, shipment, delivery confirmation and an
issued invoice. Rollback re-verified with every domain on fixed data and the
backend stopped.
Also checks off Wave 3 in docs/TBD-migrate-wave.md and re-points that file at
the mock content that remains.
OpenSpec change: openspec/changes/replace-mock-api-wave-3
Wave 2 of replacing the fixed-data mock adapter: the auth domain joins the live
list, so credentials, roles and tokens belong to the real user.
- session: validate a restored token through /auth/me instead of trusting
localStorage, clearing it on 401/403 but keeping it when the API is merely
unreachable; the route guard now uses the validated session
- login: report a 401 as invalid credentials rather than a generic failure, and
drop the 6-character client rule so the API owns the password policy
- register: raise the rule to the API's 8 characters, remove the
verification-code field (its button only counted down and the value was never
sent), and report a duplicate email (409) distinctly
- TopBar and the user profile render their session-dependent branch client-only:
validating the session before hydration made those localStorage-backed
branches report hydration mismatches the previous code did not
Verified against the running backend: wrong password rejected, real JWT issued,
/user reachable, a short password refused with no network call, duplicate email
reported, a tampered token cleared and bounced to sign-in, a stale token kept
when the API is down, and the fixed-data rollback still signs in with the
backend stopped.
Also re-cuts docs/TBD-migrate-wave.md: auth is done, and cart, orders,
shipments and invoices must move together, because the mock adapter keeps their
state in one shared object and a partial flip fails at checkout.
OpenSpec change: openspec/changes/replace-mock-api-wave-2
Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API
adapter per domain, with catalog and currency served live while auth, cart,
orders, shipments and invoices stay on fixed data.
Backend:
- seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API
exposes no category write route, so this cannot come from the seed script
- filter public product listing by the category subtree with a recursive CTE,
matching the mock's existing behaviour instead of exact-match
- add sort=price with order=asc|desc, validated by hand so an unsupported value
returns the project's ApiError 400 shape rather than axum's own rejection
Mall:
- replace the all-or-nothing mockApi boolean with a liveDomains list composed
through a typed per-domain pick map
- source home floors, the category menu, search and product detail from the
catalog API; banners, promos, quick links, store card and comment/coupon
content stay local display-only content
- drop the brand facet and the sales/comments sorts: no backend model backs them
- fix salesOf/commentCountOf, which parsed digits out of the product id and so
rendered "NaN sold" for live UUID ids; they now hash the id
Seed: 24 products across 4 shops, idempotent on re-run.
Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API
to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work.
OpenSpec change: openspec/changes/replace-mock-api-wave-1
Home hero becomes a 450px flex row with a pinned 240x450 category rail
(ShellCategoryMenu `pinned` mode) left of the 960px center-cropped
carousel; hovering a top-level category expands the mega-menu over the
carousel. The header no longer collapses on scroll, and its category
dropdown is now hover-only on non-home pages.
OpenSpec change: openspec/changes/pin-home-category-menu
BREAKING CHANGE: the header logo/search/cart bar no longer auto-hides
past 200px of scroll, and the home page no longer shows the header
category dropdown (it is pinned in the hero row instead).