The merge creates the brand capability (two requirements) and extends the catalog browse requirement with the brand filter, the sales sort and a real sold count. openspec validate --all --strict stays green at 14 specs. With every box now checked, docs/TBD-migrate-wave.md is deleted, as its own instructions require. Its two durable notes move to the README instead of disappearing with it: - `nuxt build` does not typecheck, so a green build is not the type gate the verification section claimed. AGENTS.md told me to treat it as one, which is why waves 1-5 over-reported "builds pass" as evidence. - the list of what stays deliberately mock: addresses, coupons, favourites, account stats, the seckill/collective/integral pages, reviews, and the fixed-data adapter itself, which the adapter spec requires for rollback. The README's verification block also gains the real test count (29, not 16).
4.9 KiB
Design
Context
See proposal.md — Why. Wave 1 removed three things the catalog could not answer: the brand facet, and the sales and comments sorts (search.vue keeps only newest and price; catalog.rs accepts only sort=price).
Two of them now have real backing:
order_items(apps/api/migrations/0004_orders.sql) recordssku_idandqtyper order line, andskus.product_idties those to products — so units sold is derivable.productshas no brand column, andProductUpsertBody(packages/shared/src/api.ts) carriescategory_id,slug,name,descriptionandimagesonly, so merchants cannot set a brand today either.
The mall's current figures are hashes of the product id: salesOf and commentCountOf in apps/mall/mock/data.ts. ProductCard.vue renders both as "N sold · N reviews", and pages/goods/[id].vue renders a reviews tab with invented reviewers, ratings and replies.
Goals / Non-Goals
Goals:
- Both remaining facets answer from real data: the brand filter from a brand table, the sales sort from order lines.
- Nothing in the product surfaces presents an invented figure as fact.
Non-Goals:
- No reviews capability. Writing, moderating and displaying reviews is a feature with its own lifecycle.
- No admin or shop-admin console UI for brands this wave — the API and the seed are enough to make them real.
- No change to the seckill, collective or integral pages, nor to coupons, favourites, addresses or the session cookie.
Decisions
1. Brands are reference data in their own table, with a nullable products.brand_id.
Mirrors categories: a flat list with a position, administered centrally, referenced by products.
Alternative: a free-text brand on each product — no filterable vocabulary, and every shop would spell the same manufacturer differently.
2. PUT /api/admin/brands replaces the ordered list, as storefront content does.
Small ordered lists are edited whole; replacing reindexes positions and makes reordering, adding and deleting one operation.
3. A "sale" is a unit on an order that reached payment.
sold_count counts order_items.qty joined to orders whose status is paid, fulfilling, shipped or completed. Orders still pending_payment and cancelled orders do not count: a cart that was abandoned is not a sale, and a cancelled one was refunded by stock restore.
Alternative: counting every order line inflates the figure with abandoned checkouts.
4. sold_count is computed per read, not stored.
A grouped aggregate over order_items joins the existing product queries, so the number cannot drift from the orders that produced it.
Alternative: a products.sold_count column maintained when payment or cancellation happens — faster, but it makes correctness depend on every future order-path change, and this is cheap at MVP scale.
5. The review UI goes, and reviews become declared future work.
The product cards drop their review figure and the product detail page drops its reviews tab, summary and reply blocks. The mall attributes comments to named shoppers and shows a "good rate" percentage; a migration that turns everything else real has no business keeping invented opinions on screen. The after-sale tab stays, because it shows the shop's own after_sale copy.
Alternative: keep the reviews as sample content labelled as such — rejected: a demo label on a review list does not survive the page being shown to anyone else, and the data is attributed to customers.
6. Merchants set a product's brand through the existing product upsert.
ProductUpsertBody gains an optional brand_id, so seed-demo.mjs can assign brands to the demo products and merchants can do the same from their console.
Alternative: assign brands by direct SQL in the seed — bypasses the API the console uses, and leaves the field unsettable in the product form.
Risks / Trade-offs
- [Removing the reviews tab is a visible feature loss] → deliberate, recorded in the proposal and the tracker, with a reviews capability named as the way back.
- [The sales aggregate runs on every listing query] → acceptable at this scale; a stored counter is the escape hatch if it ever matters, and decision 4 says why it is not the default.
- [A brand can be deleted while products reference it] →
products.brand_idisON DELETE SET NULL, so products survive with no brand rather than disappearing. - [The brand facet reappears with an empty list until brands are seeded] → the seed assigns the demo brands, and the facet renders only when brands exist.
Migration Plan
- Ship the migration (brands table plus the nullable column), the brand endpoints and the
sold_countaggregate; nothing in the mall reads them yet. - Seed brands and assign them to the demo products.
- Flip the search facet, the sorts, the card figure and the detail page in one commit.
- Rollback: revert those pages. The brand tables are additive, and the fixed-data adapter keeps serving
brandIdfromPRODUCT_BRAND.