This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
@@ -0,0 +1,99 @@
# ADR-0020: Promotions and coupons are one entity with one engine
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
A storefront needs two things that look different and are not: promotions that
apply themselves ("20% off jackets this week") and coupons that apply when
someone types a code ("SUMMER20").
Modelled separately they get separate tables, separate admin screens and
separate rule evaluation. Then both have to answer the same questions — does
this stack with that, what happens when the total would go negative, which wins
when two apply, how is a percentage rounded — and the two answers drift. The
drift is not caught by tests, because each engine is self-consistent. It is
caught by a customer whose total is wrong.
## Decision
**One `Discount` entity with a `trigger`.** `AUTOMATIC` applies on its own;
`CODE` requires a code. Everything else — type, scope, value, window, usage
limit, stacking, priority — is shared, because it genuinely is.
**The arithmetic is a pure function.** `discount-engine.ts` takes a snapshot of
candidates and lines and returns amounts. No database, no clock, no I/O. This is
the one place in the system where a rounding mistake is a financial one, so it
is the one place that can be exhaustively tested without a database — and it is.
**Eligibility is resolved outside it.** Whether a discount is live, within its
window, or has uses left depends on state the engine deliberately cannot see.
`PromotionsService` answers those and hands the engine a decided list.
**Stacking is `stackable` plus `priority`.** Ascending priority, then id, so the
outcome never depends on the order rows came back in. A non-stackable discount
that applies ends evaluation; one that would apply after another already has is
rejected as `NOT_COMBINABLE`.
**Percentages floor, never round.** Rounding up hands out a fraction of a đồng
the merchant never agreed to, on every order.
**Each discount applies to what is left, not the original subtotal.** Two 50%
offers take 75%, not 100%.
**Usage limits are claimed with a conditional UPDATE**, guarded on the limit —
the same shape as stock reservation (ADR forthcoming in §15 of architecture.md).
Two shoppers redeeming the last use simultaneously must not both win.
**Redemptions are recorded with a snapshot amount**, and cancelling an order
hands its uses back.
## Consequences
One admin screen, one permission, one set of rules to reason about. A coupon is
a promotion that needs a code typed, and the data model says so.
`coupon.manage` was deleted from the permission catalog rather than left
unused. A permission nobody checks is worse than no permission: it reads as a
capability a role can be granted, and the first person to grant it will expect
it to do something.
The one place the merge is visible as a compromise is the editor, where
`trigger` is the first control on the form — it decides whether the rest of the
dialog reads as "a promotion that runs by itself" or "a code a shopper types",
so it cannot sit further down.
Status on the list is derived, never `isActive` alone. A discount that expired
last week or burned its last use is still `isActive: true`, and a screen that
reports a promotion as running when it is not is worse than no screen.
The engine's purity is what makes the money maths trustworthy: twelve tests
cover flooring, over-discounting, compounding, determinism and empty carts
without touching Postgres.
The cost is that some fields are meaningless for some triggers — an automatic
promotion has no `code`. That is enforced in validation rather than by the
schema, which is the usual trade for avoiding two near-identical tables. The
editor disables the field rather than hiding it, so the rule is visible instead
of mysterious.
Retiring is a soft delete, because `DiscountRedemption.discount` is
`onDelete: Restrict`: an order that received a discount must keep pointing at
the thing it received. So a retired discount stops applying rather than ceasing
to exist, and the admin says "Retire" rather than "Delete" for that reason.
Per-customer limits are absent: they need customer identity, which arrives with
M8. The column is deliberately not there yet rather than present and ignored.
## Alternatives considered
**Separate `Promotion` and `Coupon` tables.** Clearer names, two engines that
must agree forever. Rejected — the agreement is the hard part, and it does not
hold.
**A rules DSL stored as JSON.** Maximum flexibility, no type safety, and every
rule change becomes a data migration nobody can review. Rejected as premature.
**Computing discounts on the client.** Instant feedback, and it makes the total
a negotiation. Every price in this system is the API's to decide (ADR-0011).
@@ -0,0 +1,102 @@
# ADR-0021: A review is anchored to an order line
- **Status:** Accepted
- **Date:** 2026-08-13
## Context
Product reviews are worth having only if they are worth believing. The usual
shape — an open form on the product page, with a "verified purchase" badge
awarded to reviews the system can match back to an order — gets this backwards.
The badge becomes the exception, unverified reviews become the bulk of the
content, and moderation becomes a full-time job rather than a screen someone
checks.
The badge is also weaker than it looks. Matching on product + email means
anyone who guesses a customer's address can post as them, and anyone who bought
once can review every colourway.
Meanwhile this store has no customer accounts yet (M8). A design that depends
on login would mean no reviews until then.
## Decision
**`Review.orderLineId` is unique and required.** A review hangs off the exact
purchased item, not off a product plus a claim about who is writing.
This makes "verified purchase" **structural**. There is no unverified review
because there is no row to put one in. `isVerifiedPurchase` is sent to the
storefront as a constant `true` — not because the check is skipped, but because
the schema already made it unfalsifiable.
The unique constraint gives **one review per item purchased** for free. Buying
the same jacket twice earns two reviews; buying it once earns one.
**Authorisation is the order id plus the email on that order.** The same bar
the guest order lookup already sets, and the same capability URL the
confirmation page uses (ADR-0019's neighbour: the id is a UUIDv7, unguessable,
and holding the link is the authorisation). No account required, which is what
lets reviews ship before M8.
Wrong email and unknown order return the **same 404**, so the endpoint cannot
be used to test whether an order exists.
**Everything starts `PENDING`.** Publication requires a decision. The
alternative — publish then moderate — means the first person to read abuse on a
product page is a customer.
**The rating aggregate lives on `products` as `rating_sum` + `rating_count`,**
and is **recomputed**, never incremented. Two integers rather than a stored
average, because approving one more 4-star review is `sum + 4, count + 1`:
exact, and reversible. See Consequences.
## Consequences
A shopper cannot review a product they own but bought elsewhere. That is the
correct trade here: the alternative is an open submission endpoint, which is a
spam surface needing a moderation _team_ rather than a moderation _screen_.
Reviews work for guests today and keep working when accounts arrive — an
`orderLine` already reaches a `customerId` through its order when there is one.
Recompute-not-increment is what makes a moderator's decision reversible. Every
incremental scheme needs a compensating delta per path (approve, reject,
un-reject, edit), and the first missed path leaves a product displaying a
rating no review supports, with nothing to notice it by. Recomputing from the
approved rows is one statement that is correct from any starting state — and it
is exercised: rejecting an approved review takes its stars back out.
Moderation drops the whole catalog cache. `products.rating_*` is part of the
read model, `cache-keys.ts` states the contract as "invalidated on write, TTL
as a safety net", and without this a moderator approves a review, reloads the
product page, sees the old figure for up to five minutes and concludes the
button is broken.
The distribution is sent alongside the average because they answer different
questions. A 3.0 of straight 3s is a mediocre product; a 3.0 of 5s and 1s is a
product with a sizing problem. Only the bars distinguish them.
Ratings are constrained `BETWEEN 1 AND 5` in the database as well as in Zod.
The column feeds a stored SUM, so one bad row skews a product's average
silently and permanently.
## Alternatives considered
**Open reviews with a verified badge.** More content, most of it unverifiable,
and a badge that a determined poster can forge by guessing an email.
**Require an account.** Cleanest identity story, and it would have meant no
reviews at all until M8 — while making guest buyers, who are most of them,
second-class.
**Store the average directly.** One column instead of two, and it must be
recomputed from scratch to stay honest anyway — at which point it is strictly
worse than the pair, since it also accumulates floating-point drift.
**Publish immediately, moderate later.** Faster for the honest majority, and it
puts the store's name under whatever the first bad actor writes.
**Gate on delivery ("only review what arrived").** Correct in principle and
currently unknowable: the store has no shipping integration until M10, so
"delivered" is not a fact the system holds. Cancelled orders are refused; the
rest are allowed, and the gate tightens when the data exists.
@@ -0,0 +1,102 @@
# ADR-0022: Editorial content is Markdown, not a page builder
- **Status:** Accepted
- **Date:** 2026-08-13
## Context
The store needs a journal and a handful of static pages — returns policy,
shipping, about. That is a small requirement with a large gravitational pull:
every CMS starts as "just some pages" and ends as a block tree, because the
first time an editor wants two columns, someone adds a two-column block.
Once that happens the storefront's design stops being code. Layout decisions
move into the database, the React components become a rendering engine for
whatever an editor assembled, and the careful type scale and spacing this
project has spent seven milestones establishing become suggestions. That is the
outcome this entire project exists to avoid — it is why it is not WooCommerce.
## Decision
**One `ContentEntry` with a `type` of `PAGE` or `POST`,** rather than two
tables. The shared surface — per-locale slug, title, body, SEO, publish state,
soft delete — is nearly all of it. What differs is placement: a POST is listed
in a feed newest-first and carries an excerpt and a cover; a PAGE is addressed
directly and never listed. Same reasoning as ADR-0020.
**The body is one Markdown column.** No blocks, no tree, no layout. An editor
chooses _what it says_; the storefront decides _what it looks like_.
**Markdown is rendered to React elements, never to an HTML string.**
`react-markdown` parses to a component tree, so `dangerouslySetInnerHTML`
appears nowhere in this path. A `<script>` in a body is escaped text because it
never becomes a tag — injection is structurally impossible rather than
sanitised away, which is the same move as anchoring reviews to order lines
(ADR-0021).
**Every element is mapped explicitly.** Unstyled `<h2>`/`<p>` inheriting
browser defaults is precisely how a CMS page ends up looking like a different
website. Body `<h1>` is demoted to `<h2>` because the page already renders the
title as `<h1>`, and internal links go through the locale-aware `Link` — a raw
`<a href="/men">` in a Vietnamese post drops the locale prefix, a bug already
fixed three times elsewhere in this storefront.
**Images in bodies are dropped.** They would bypass the media library, hotlink
to arbitrary hosts, and arrive without dimensions — a layout shift on every
page they appear on. Posts get one cover image, from the media library, with
known dimensions.
**`publishedAt` is stamped once, on first publish, and never rewritten.**
Re-stamping on save would jump a post to the top of the feed because somebody
fixed a typo, and would silently change a date a reader may already have cited.
**Slugs are per-locale**, like products, because `/en/blog/how-to-layer` and
`/vi/blog/cach-phoi-do` are the SEO surface. A slug from any locale resolves,
then redirects to the canonical one for the locale being browsed.
**Pages live at `/pages/[slug]`.** Not at the locale root: a catch-all there
would compete with `/men`, `/cart` and `/search`, and make every genuine 404
ambiguous. `/pages/returns` is uglier than `/returns` and cannot silently
shadow a real route.
## Consequences
An editor writes in Markdown. That is a real constraint on non-technical staff,
and the correct one at this size — the alternative is a rich-text editor whose
output must then be sanitised, which is a larger surface than the whole feature.
Publishing is one decision with two buttons: _Save draft_ and _Publish_. A
status dropdown plus Save reads as neither, and publishing is the consequential
action.
Deleting is soft, and also unpublishes. A soft-deleted row still marked
`PUBLISHED` is one forgotten `deletedAt: null` away from being live again.
Translations are replaced wholesale on save rather than upserted per locale, so
removing the English version of a post actually stops `/en/blog/<slug>`
resolving instead of serving the copy the editor just deleted.
The `/content/{posts,pages}/slugs` endpoints return titles alongside slugs.
They serve three callers — `generateStaticParams`, the footer's page list, and
a sitemap when one is built — which is cheaper than three endpoints returning
the same rows.
Homepage blocks are **not** built. The module's original sketch mentioned them;
they are the exact feature that turns this into a page builder, and the
homepage is better served by code until there is a concrete editorial need.
## Alternatives considered
**A block/section tree.** Maximum editorial flexibility, and it hands layout to
the database. Rejected — see Context.
**MDX with embedded components.** Lets a post drop in a product carousel, and
makes content executable code that must be built and deployed. Rejected for
content authored through an admin UI at runtime.
**HTML stored directly with sanitisation.** Familiar to editors, and every
sanitiser is a denylist that someone eventually gets past. Rendering to React
elements needs no denylist.
**Separate `pages` and `blog_posts` tables.** Clearer names, two admin screens,
and two sets of publishing rules that must agree forever.
+3
View File
@@ -28,6 +28,9 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
| [0018](./0018-orders-snapshot-everything-they-display.md) | Orders snapshot everything they display | Accepted |
| [0019](./0019-order-placement-is-idempotent-by-client-key.md) | Order placement is idempotent by client key | Accepted |
| [0020](./0020-promotions-and-coupons-are-one-entity.md) | Promotions and coupons are one entity with one engine | Accepted |
| [0021](./0021-a-review-is-anchored-to-an-order-line.md) | A review is anchored to an order line | Accepted |
| [0022](./0022-editorial-content-is-markdown-not-a-page-builder.md) | Editorial content is Markdown, not a page builder | Accepted |
## Decisions deliberately NOT recorded yet