Wip stage M4

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 5386bc51d1
commit d5cacc1208
113 changed files with 9486 additions and 919 deletions
@@ -0,0 +1,71 @@
# ADR-0016: Option values are retained when variants reference them
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
A merchandiser removes the "Red" colourway from a product. What should happen to
the three Red variants and to the Red option value itself?
Three things reference them and each has a different claim:
- **Order history** — an order line points at a variant id, and reading that
order later needs "Red / M" to still mean something.
- **The variant matrix** — Red must stop generating combinations.
- **The storefront** — a shopper must not see a Red swatch that can never be
selected.
The database already takes a position: `ProductVariantOptionValue.optionValue`
is `onDelete: Restrict`, precisely so that deleting a value cannot silently
orphan or cascade into history.
## Decision
**Variants are archived, never deleted.** A removed combination sets
`status = ARCHIVED`. The row, its SKU and its option links all survive, so an
order placed last month still resolves.
**Option values are deleted only when nothing references them.** If any variant
— active or archived — still links to a value, the value is _retained_. It stops
appearing in the matrix and stops being offered, but it continues to exist so the
archived variants remain readable.
**The matrix is built from the operator's submitted option set, not from the
database.** Retained values would otherwise regenerate the very variants that
were just archived.
**The storefront filters option values to those offered by at least one active
variant.** Without this last step, a retained value renders as a permanently
disabled swatch with no explanation.
## Consequences
Order history stays intact and readable, which is the constraint that drove
everything else. Re-adding a removed colourway later reuses the retained value
and its translations rather than creating a duplicate.
The cost is that `product_option_values` accumulates rows that are invisible to
shoppers, and "delete" is not always literally a delete — a merchandiser who
inspects the database will find values they thought they removed. The schema
comment and this ADR are the mitigation; a future admin screen could surface
retained values explicitly.
This was found the hard way: the first implementation deleted values before
archiving variants and hit the `Restrict` constraint as a 500. The failure was
the schema doing its job.
## Alternatives considered
**Cascade the delete.** Removes the option value and its variant links, which
either breaks order lines or cascades into them. Rejected — this is the outcome
`Restrict` exists to prevent.
**Soft-delete option values with an `isActive` column.** Functionally similar to
retention, but adds a column and a filter to every catalog query for a case that
is already handled by "is any active variant offering this?". Rejected as
redundant state.
**Refuse to remove a value that has variants.** Simple and safe, but forces the
merchandiser to archive six variants by hand before they can drop a colourway —
pushing bookkeeping onto the person the tool exists to help.
@@ -0,0 +1,90 @@
# ADR-0017: shadcn/ui for infrastructure, hand-built for brand
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
The component layer started as four hand-rolled primitives in `@sport/ui`
(Button, Input, Badge, Skeleton). That was enough while the surface was static
text and links. It stopped being enough the moment real interaction arrived: the
mobile filter panel needs focus trapping and scroll locking, the product editor
needs tabs, row actions need a menu, and the media picker needs a modal.
Writing those by hand means writing focus management, `aria-modal` wiring,
escape handling, portal placement and collision detection. That work is
well-understood, easy to get subtly wrong, and worth nothing to this store's
customers — nobody chooses a running jacket because the dialog traps focus
correctly. They only notice when it doesn't.
The opposite is true of the product card, the header, the hero and the PDP.
Those _are_ the store. A generic card component would make them look like every
other template on the internet, which is precisely the outcome the visual
direction exists to avoid.
## Decision
**Two tiers, split on whether a component carries brand.**
`packages/ui/src/components/ui/` holds shadcn/ui components, owned as source in
this repo and shared by the storefront and the admin: Button, Input, Dialog,
Sheet, DropdownMenu, Tabs, plus Badge and Skeleton. Registry components are
copied in essentially unedited so that anything else from shadcn drops in later.
`apps/storefront/src/components/commerce/` holds everything that carries the
brand, built by hand: hero, mega menu, site header, product card, product
gallery, product detail, filter sheet. These are storefront-only and are never
promoted to `@sport/ui`, per the existing rule in architecture §4.
**A semantic token layer adapts shadcn to the palette.** shadcn writes against
role tokens (`--background`, `--primary`, `--ring`, `--radius`); the store's
palette is `ink`/`volt`. `packages/config/tailwind/theme.css` maps one onto the
other, so a registry component already looks like this store before it is
touched. Note that shadcn's `--accent` is a hover surface, not a brand accent —
it maps to `ink-100`, and volt stays applied deliberately.
**Supporting libraries, each for one job.** Lucide for icons, Motion for the
hero's entrance, Embla for the PDP gallery's swipe.
## Consequences
The dialog/sheet/menu behaviour we would otherwise have written badly is now
correct and not our problem. `asChild` removed a real defect class: `<Link>`
wrapping `<Button>` was producing `<a><button>`, which is invalid HTML and
announces as two nested controls.
Registry components import `@/lib/utils` and `@/components/ui/*`. A workspace
package cannot rely on an app's tsconfig aliases, so those imports are rewritten
to relative paths on the way in. That is a one-line edit per component and is
documented in the `@sport/ui` barrel.
Two registry defaults had to be overridden, both because they assume a light
page: `outline` shipped with `bg-background`, which rendered a white button with
white text on the black hero, and the base sizes are rounded and lowercase where
this brand is square and uppercase. Owning the source is what made those
one-line fixes rather than a fight.
The cost is a larger dependency surface — `radix-ui`, `lucide-react`, `motion`,
`embla-carousel-react`, `tw-animate-css` — and a standing obligation to keep
copied components in step with upstream by choice rather than by `npm update`.
## Alternatives considered
**Keep hand-rolling everything.** Total control, and the existing primitives were
good. Rejected once the list of things to build became "focus trap, scroll lock,
portal, collision detection" — that is a component library, and writing one is
not this project.
**Adopt a full component library (MUI, Mantine, Chakra).** Faster to start and
far harder to make look like anything but itself. The visual direction here is
the deliverable, and fighting a theme system to reach it is worse than building
the branded parts by hand.
**Use shadcn for everything, including the product card and header.** This is the
common failure mode. It produces a store that works correctly and looks like a
demo. The split above exists precisely to prevent it.
**Per-app copies of the shadcn layer instead of a shared package.** Matches
shadcn's single-app default and lets the two apps drift freely. Rejected because
Button and Input genuinely are identical in both, and two copies means two
places to fix the next `bg-background` problem.
+2
View File
@@ -24,6 +24,8 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
| [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted |
| [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted |
| [0015](./0015-frontends-reach-the-api-through-their-own-origin.md) | Frontends reach the API through their own origin | Accepted |
| [0016](./0016-option-values-are-retained-when-variants-reference-them.md) | Option values are retained when variants reference them | Accepted |
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
## Decisions deliberately NOT recorded yet
+60 -20
View File
@@ -102,7 +102,9 @@ a framework, one of those consumers breaks.
- Domain types and API contracts (`@sport/types`)
- Input shape/format rules (`@sport/validation`)
- API access (`@sport/api-client`)
- Design-system primitives: Button, Input, Badge, Skeleton (`@sport/ui`)
- UI infrastructure: Button, Input, Dialog, Sheet, DropdownMenu, Tabs, Badge,
Skeleton (`@sport/ui`, shadcn/ui owned as source — see
[ADR-0017](./adr/0017-shadcn-for-infrastructure-hand-built-for-brand.md))
- Build configuration (`@sport/config`, `@sport/eslint-config`)
**Do not share** — things that only look shareable:
@@ -110,7 +112,10 @@ a framework, one of those consumers breaks.
- **Domain components.** `<ProductCard>` knows about sale badges, price ranges and colour
swatches. It belongs to the storefront. The admin's product row needs status, stock and
margin. Merging them produces a component with fourteen props and two consumers who both
fight it.
fight it. These live in `apps/storefront/src/components/commerce/` — hero, mega menu, site
header, product card, product gallery, product detail, filter sheet — and are built by hand
because they _are_ the brand. The dividing line: infrastructure comes from the registry,
identity is written here.
- **Business logic.** It lives in the API. A discount calculated in a shared package is a
discount that can disagree with the invoice.
- **App state.** Cart, auth session and filter state are app-specific. Shared stores create
@@ -418,23 +423,27 @@ Places where the instinct to generalise should be resisted until a second real c
## 14. Architectural risks to prevent from day one
| Risk | Why it is fatal later | Prevention in place |
| ------------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) |
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) |
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) |
| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) |
| Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels |
| Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) |
| Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation |
| Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) |
| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) |
| Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start |
| Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored |
| No request correlation | Production incidents become guesswork | `x-request-id` end to end |
| Browser-only failures passing every check | Server rendering and curl both succeed while every click fails | Client paths must be exercised in a real browser before a milestone closes |
| `onUnauthorized` retrying the refresh call | Unbounded refresh loop hammering the API from the browser | `skipAuthRetry` on all auth endpoints plus a single-flight refresh |
| Concurrent 401s each rotating the token | The second rotation reads as token reuse and revokes the family | One shared in-flight refresh promise |
| Risk | Why it is fatal later | Prevention in place |
| ---------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) |
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) |
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) |
| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) |
| Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels |
| Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) |
| Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation |
| Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) |
| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) |
| Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start |
| Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored |
| No request correlation | Production incidents become guesswork | `x-request-id` end to end |
| Browser-only failures passing every check | Server rendering and curl both succeed while every click fails | Client paths must be exercised in a real browser before a milestone closes |
| `onUnauthorized` retrying the refresh call | Unbounded refresh loop hammering the API from the browser | `skipAuthRetry` on all auth endpoints plus a single-flight refresh |
| Concurrent 401s each rotating the token | The second rotation reads as token reuse and revokes the family | One shared in-flight refresh promise |
| Editing options wiping variant pricing | A merchandiser's price and stock work vanishes on a cosmetic edit | Order-independent combination signatures; `planVariantMatrix` is pure and tested ([ADR-0016](./adr/0016-option-values-are-retained-when-variants-reference-them.md)) |
| Deleting an option value referenced by history | Breaks or cascades into past orders | `Restrict` FK plus retain-if-referenced ([ADR-0016](./adr/0016-option-values-are-retained-when-variants-reference-them.md)) |
| A catalog write not invalidating the cache | Edits appear not to work, so operators repeat them | Every write path ends in `afterWrite` → `deleteByPrefix('catalog:')` |
| Stock edited outside the ledger | "Why is this number wrong?" becomes unanswerable | Stock is not settable on the variant endpoint; it moves only through inventory |
---
@@ -456,19 +465,50 @@ interactions have been clicked in a real browser**, with the console and network
Regression tests now cover the first two (`packages/api-client/src/http-client.spec.ts`); the
third belongs in an end-to-end test when one exists.
Authoring the M3 editor added a second rule. Two defects survived a green pipeline and a
successful-looking click-through, and both needed the _second_ step of a flow to appear:
- The variant grid saved correctly, showed "saved", and left every row marked dirty. It compared
its draft against a `product` prop the editor shell never refreshed, so a save could not be seen
by the thing measuring whether one was needed. One save looked fine; it was saving _twice_ that
exposed it. The shell now owns the product and every tab hands its result back.
- The inventory screen listed `StockLevel`, which is a projection of the ledger. A variant that has
never moved has no row — so freshly created variants were invisible there, and since that screen
is the only route to an adjustment, they could never be given stock at all. Every seeded product
had stock already, so the whole catalog looked healthy. Only a product created from scratch
showed it.
M4's listing controls added a third variant of the same lesson. Sorting a listing _after_
loading more products showed the same product twice — once from the freshly sorted first page
and once left over from the products appended under the previous order. `LoadMore` keeps its
position in the React tree across a soft navigation, so its `useState` survived a query change
the server had already acted on. Neither action alone revealed anything; only the pair did. It
is keyed on the listing identity now.
The same pass caught a link built with a raw `<a href>` instead of the locale-aware `Link`, which
sent an English shopper following "load more" to the Vietnamese listing — invisible in the
default locale, which is exactly why it survived.
So: **exercise a flow on data you just created, not only on seeded data, perform each action
twice, and do it in a non-default locale.** Seed data has been through every code path already; new data has been through none. The
inventory case is pinned in `apps/api/src/modules/inventory/inventory-list.spec.ts`.
---
## 16. Deliberate limitations
Stated plainly so they are choices rather than oversights.
**Shipped (M0–M2)**
**Shipped (M0–M3)**
- Catalog reads: products, variants, options, categories, collections, brands, navigation —
localised, cached, filtered and faceted.
- Storefront browsing and PDP in Vietnamese and English, with per-locale slugs and `hreflang`.
- Auth: login, refresh rotation with reuse detection, audience separation, login throttling.
- RBAC enforcement end to end, plus user administration and a role viewer in the admin.
- Admin catalog authoring: product create/edit with per-locale content tabs, an option builder that
regenerates the variant matrix, per-variant SKU and pricing, imagery assigned per colourway,
media uploaded straight to storage, and stock received through the append-only ledger.
**Not built yet, and why**