Storefront Integration
How happy-shop-frontend consumes the API — CMS pages, cache tags, banners, analytics and account deletion — and the contracts that must not drift.
Storefront Integration
The customer storefront (happy-shop-frontend) holds no database. Everything it renders comes from
this API, and the seams between the two repos are where the expensive failures live: neither
compiler can see across the boundary, and almost every mismatch degrades silently rather than
erroring.
This page records the contracts as they stand, and — more usefully — the ways each one has already been got wrong.
1. Cache invalidation has two producers, not one
Both the API and the admin panel POST to the storefront's /api/revalidate. It verifies an
HMAC-SHA256 over `${timestamp}:${body}`, sent as x-signature with x-timestamp, and checks
freshness before the signature so a captured request cannot be replayed indefinitely.
| Producer | Secret it signs with | Must equal |
|---|---|---|
| API | NEXT_REVALIDATE_SECRET (backend .env) | NEXT_REVALIDATE_SECRET (storefront) |
| Admin | STOREFRONT_REVALIDATE_SECRET (admin .env.local) | the same storefront value |
A mismatch on either side is silent. The storefront answers a bare 401 with no detail — by
design, since the route can purge the entire site's cache — and the caller logs and continues,
because a mutation that already committed must never 5xx over a cache.
This was live in the local environment: the API's secret matched and the admin's did not, so
every admin-triggered purge was rejected. The only evidence anywhere was one
{"event":"revalidate_rejected","reason":"invalid_signature"} line in the storefront log and one
storefront cache invalidation failed in the admin's. Both apps looked healthy.
The tags the storefront actually subscribes to
STOREFRONT_TAG in cache-invalidation.tags.ts is the authoritative list, and it exists because
revalidateTag is matched by string equality across two repositories — a tag nobody subscribes
to is not invalidation, it is a log line.
banners · blog · catalog · content · faq · page-seo · products · reviews · runtime-configEvery consumed domain emits its coarse tag alongside its granular ones. That is deliberate and
additive: a rename needs both repos to deploy together, and a half-done rename must never leave
zero tags matching. cache-invalidation.tags.spec.ts asserts the mapping per domain and names the
domains that deliberately reach no consumer, so adding one stays a decision rather than an
oversight.
emi, promotion and shipping are the remaining unconsumed three. banner left that list when
the storefront grew a banner surface.
2. CMS pages — ten keys, and what renders each
The storefront resolves every page through getContentPage(pageKey) and treats a failure as
"nothing authored": each resolver catches, returns undefined, and every field falls back to a
fixture. A CMS outage must never cost a page its content — the header and footer render on every
route, so an uncaught throw there is a site-wide 500.
| Page key | Sections | Storefront surface |
|---|---|---|
home | hero, how_it_works, faq_intro, related_blogs_intro, cta_banner | app/page.tsx + _lib/home-content.ts |
about | hero, how_it_works, about_stats, why_choose, our_mission | about/_lib/about-content.ts |
faq | hero, faq_intro | faq/_lib/faq-content.ts |
contact | hero, contact_info, contact_form | contact/_lib/contact-content.ts |
blogs | hero | blog/page.tsx |
privacy_policy | document, data_flow_summary | privacy-policy/page.tsx |
return_policy | document | return-policy/page.tsx |
terms_of_service | document | terms-of-service/page.tsx — seeded with copy that states it is a draft, in the body and not only in a comment, so a CMS outage cannot fall back to text that reads as binding |
global_header | announcement_bar, contact_info, quick_links | components/layout/header-section.tsx |
global_footer | brand_blurb, quick_links, contact_info, legal_links, copyright | components/layout/footer-section.tsx |
Sections render only when authored. A page with nothing in the CMS looks exactly as it did before the CMS existed, which is what makes adding a renderer safe.
contact_info is a union, and both arms use one key
The footer/header arm is {title, contacts[], address?, openingHours?}; the contact page's is
{email, phone, responseTime}. Both validate on their own page, so a consumer must require the
fields of the arm it wants — safeParse on the wrong arm is what stops the footer's heading being
rendered where a phone number belongs.
address and openingHours were added for the footer. The storefront derives the store map's URL
from whichever address won, so authoring the address moves the pin: an address and a map that
disagree is worse than either being stale.
The CMS URL validator rejects tel: and mailto:, so a contact row's href can never be stored.
Every surface builds it from the label instead — digits and a leading + only, so "+977 1 5555555"
still produces a dialable link.
quote and story_blocks are reserved
Both are declared in CONTENT_SECTION_KEYS and assigned to no page. That is deliberate, and
content-schema.registry.spec.ts says so and asserts it. They are not orphans and need no cleanup.
3. Banners
GET /api/mobile/banners/serve/:slug returns {placement, banners[], slots[], generatedAt}.
Prefer ctaUrl. The API resolves it at serve time from the target's current slug, so a client
must never build a URL from target.slug or cache one.
A child category's URL nests under its parent. The storefront routes exactly two category
shapes — /categories/[parent] and /categories/[parent]/[child] — and cannot derive a parent from
a child slug. buildTargetUrl used to emit a flat /categories/{slug} for every category, so every
banner targeting a child category linked to a 404. Nothing could see it: the response carried a
well-formed URL and only a click on a live campaign revealed it.
The serving query now left-joins the parent and its current slug. A category nested deeper than
one level resolves its parent to null on purpose — the storefront has no page for it, and naming
its immediate parent would build a URL that still 404s while looking more correct.
Tracking
POST /banners/events/impression and /click both require bannerPublicId and
placementPublicId (@IsUUID() @IsNotEmpty()), with campaignPublicId optional. The placement
id belongs to the placement, not the banner, so it has to be threaded down from the serve response.
The global pipe runs forbidNonWhitelisted, and the storefront's BFF route swallows failures by
design — so a partial body is a 400 nobody ever sees, and an event nobody ever counts. The BFF
refuses to forward unless both ids are present.
3a. Series and tag landing pages
/series/[slug] and /tags/[slug] consume GET /catalog/series/:slug and GET /catalog/tags/:slug
for the page's own copy, then filter the product list with seriesSlug or tags respectively.
Both detail endpoints return the entity only — no products — so each page makes two calls. Both also accept a historical slug, which is why each page canonicalises to the slug that comes back rather than the one in the URL: a renamed series stays reachable at its old address without two URLs both claiming to be the page.
tags is AND on the API. A tag page therefore applies exactly one — passing two would narrow
the page below what its own URL promises. Everything else on the page is a facet.
A series replaces its brand filter rather than adding to it. A series is strictly narrower, and the brand is implied by the products that come back.
Series pages are gated at two products. Nine of the fifteen series in the catalogue hold exactly one, and a listing of a single item is a worse version of that item's own product page. The storefront asks for exactly two and counts what comes back, 404s below the threshold, and uses the same helper to decide what enters the sitemap — so the page and the sitemap cannot disagree. Tags are not gated: there are six and the smallest holds six products.
4. Analytics — the ids reach the page, the CSP has to let them load
GET /config/runtime/public serves gtmId, gaMeasurementId and metaPixelId. The storefront
validates each against a pattern before interpolating it into a script tag and renders nothing for a
null.
That was all correct and the feature was still completely inert. Both storefront CSPs allowed
script-src 'self', so the moment the GTM bootstrap injected
<script src="https://www.googletagmanager.com/gtm.js"> the browser refused it. The beacons had
nowhere to go either (connect-src 'self'), and the pixel fallbacks were blocked by img-src.
Nothing throws, no server log records it, the page renders perfectly, and the only evidence is a CSP violation in the browser console.
Browsers intersect every policy they are given, so the vendor origins have to be present in the
static policy (next.config.ts) and the per-request one (lib/auth/csp.ts) — a host in one and
missing from the other is allowed nowhere.
5. Account deletion is a five-endpoint lifecycle
| Method | Path | Auth | Body |
|---|---|---|---|
POST | /auth/account-deletion/request | customer | — |
DELETE | /auth/account-deletion/cancel | customer | — |
GET | /auth/account-deletion/status | customer | — |
POST | /auth/account-deletion/recover | public | {email} |
POST | /auth/account-deletion/recover/confirm | public | {email, otp} |
The two recovery endpoints are public by necessity — an account pending deletion may already be unable to sign in, which is exactly when it needs recovering. That makes them a probe for which addresses exist, so the success message must be identical whether or not the address is registered.
Recovery is a 6-digit emailed OTP, not a magic link. ConfirmAccountRecoveryDto declares
email and otp (@MinLength(6)) and nothing else. A storefront build that assumed a ?token= in
the URL had every confirmation rejected with property token should not exist, email must be a string, otp must be longer than or equal to 6 characters — a 400 the UI showed as a generic
failure, with the real reason visible only in this API's log.
GET .../status returns {status, deletionScheduledAt?, deleteBlockUntil?} where status is
active | pending_deletion | deleted. Both dates are nullable and omitted entirely for an active
account, and the consumer keeps them as raw ISO strings — they cross a server/client boundary to
render a countdown, and a Date does not survive that intact.
6. Checkout timing
CheckoutSessionDto carries both expiresAt and expiresInSeconds. Prefer
expiresInSeconds: it is measured on the server's clock, while subtracting expiresAt from a
device clock inherits however wrong that device is. A phone five minutes fast would show a session
expired while the API still accepts it.
The session TTL is 5 minutes and the payment window 25 (CHECKOUT_HOLD_TTL_SECONDS is their sum, by
construction). Five minutes is the deadline to press Pay, not to finish paying — a session at a
gateway is exempt from expiry.
POST /checkout/{id}/cancel is refused once the status is payment_in_progress: by then the money
may already be moving and cancelling is the wrong verb. A consumer should not offer a control the
API will refuse.
See Also
- Cache invalidation — the tag registry and the domains it covers
- Content — page keys, section schemas and the admin's mirror
- Checkout — session lifecycle and close reasons