feat(mall): serve home marketing content from the API

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
This commit is contained in:
2026-09-17 16:48:10 +00:00
parent 2136a48fbe
commit 104737e4e1
17 changed files with 866 additions and 15 deletions
@@ -0,0 +1,54 @@
# Design
## Context
See `proposal.md` — Why. The home page renders four local arrays from `~/mock/data` (`pages/index.vue`): `MOCK_BANNERS` and `MOCK_PROMOS` are `{ image, url }`, `MOCK_QUICK_LINKS` adds a bilingual `label` and an inline SVG `glyph`, and each floor's advert art is picked by floor index from `/mock/floor-adv-1..6.svg`. Those assets already exist under `apps/mall/public/mock/`.
The backend has no content concept: the only shared tables are identity, catalog and orders (`apps/api/migrations/0001..0005`). The admin console already manages shops, users and currencies through `/api/admin/*` with `platform_admin` gating (`apps/api/src/routes/admin.rs`).
## Goals / Non-Goals
**Goals:**
- The home page renders every element from the backend, with no local content arrays left in `pages/index.vue`.
- Content is editable by a platform admin through the API, not only by SQL.
- The flip is visually invisible: the seeded content reproduces today's page exactly.
**Non-Goals:**
- No image upload or CDN; rows hold URLs and the seed points at the existing static assets.
- No scheduling, targeting or A/B testing.
- No content for the seckill, collective or integral pages, nor for stores, brands, coupons, favourites or addresses.
## Decisions
**1. Four typed tables, not one JSONB content table.**
`banners`, `promos`, `quick_links` and `floor_adverts` each get explicit columns, so Postgres enforces the shape and `sqlx::query_as` maps rows without hand-written validation.
*Alternative:* a single `content_blocks(kind, payload JSONB)` table is fewer lines, but every read then needs a runtime payload check, which is precisely the `any`-shaped weakness the repo's TS and Rust rules avoid.
**2. Replace a whole kind at a time instead of per-entry CRUD.**
`PUT /api/admin/content/{kind}` takes the ordered list, validates it, and rewrites that kind in one transaction, reindexing positions from array order.
*Alternatives:* twelve REST routes for four small lists is a lot of surface, and per-entry CRUD still needs a separate reorder endpoint to express order. Bulk replace makes reordering, insertion and deletion one operation, which is how the content is actually edited.
*Consequence to state plainly:* submitting an empty list clears that kind. That is intended (it is how a block is removed), and the endpoint returns the stored list so a caller can confirm what it now holds.
**3. Floor adverts are an ordered pool, not rows keyed to categories.**
The page assigns them to floors in order and wraps, matching today's `index % 6` behaviour.
*Alternative:* `floor_adverts.category_id` would let an editor target one specific floor. That is a nicer model, but it changes what the page does today and would leave floors without adverts whenever the tree grows; the pool keeps the current behaviour and the schema smaller.
**4. The quick-link glyph is stored with the row.**
It is a short SVG path string. Keeping it in content means a new quick link renders without a frontend deploy, which is the point of moving this into the database at all.
**5. A migration seeds content from the assets already in use.**
`banner-1..3`, `promo-1..3` and `floor-adv-1..6` become rows, so the page is byte-identical after the flip and the change can be verified as a visual no-op.
## Risks / Trade-offs
- [An empty submission silently clears a content kind] → intended (decision 2); the naive admin UI should confirm, and the response echoes the stored list.
- [Content rows point at frontend asset paths] → temporary coupling, called out in the non-goals; a real upload capability replaces the URLs later without touching the schema.
- [Four extra queries on every home render] → negligible at this size; if it ever matters, the four reads collapse into one query.
- [The home page loses its content if the content API is down] → the page renders the remaining blocks rather than failing (a spec scenario), and the fixed-data rollback still works because the mock client keeps serving the same four lists.
## Migration Plan
1. Ship the migration and the public read; nothing is reading it yet.
2. Flip `pages/index.vue` to the content API and drop the three mock arrays in the same commit as the admin surface, so content is never live-but-uneditable.
3. Verify the page is visually unchanged, then verify rollback with the fixed-data adapter.
4. Rollback: revert `pages/index.vue`. The content tables are additive and harmless if left in place.