Files
vmall/openspec/changes/replace-mock-api-wave-4/design.md
T
james 104737e4e1 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
2026-09-17 16:48:10 +00:00

4.4 KiB

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.