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
+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**