admin/docs/caching-strategy.md
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

345 lines
18 KiB
Markdown

# Caching strategy (Ghabilee)
**Audience:** humans and coding agents working on قبیله frontend/backend.
**Last updated:** 2026-08-31
This document is the **source of truth** for how we cache public (guest) traffic
vs authenticated (logged-in) traffic. Read it before changing SSR, `fetch`
options, `revalidate`, cookies on public pages, or discovery Redis TTLs.
Related (narrower scope):
- Client-side TanStack Query: [`consumer-caching.md`](./consumer-caching.md)
- Admin lists: [`admin-caching.md`](./admin-caching.md)
- Server `fetch` helpers: [`lib/seo/serverApi.ts`](../lib/seo/serverApi.ts)
- Backend Redis namespaces: [`backend/src/modules/events/discovery-reference.constants.ts`](../../backend/src/modules/events/discovery-reference.constants.ts)
---
## 1. Two user models — two strategies
| User model | Phase-1 volume | Goal | Strategy |
| ------------------------- | -------------- | -------------------------- | ------------------------------------------------------ |
| **Guest** (not logged in) | Very high | Fast TTFB, low server cost | **Shared HTML (ISR)** + Data Cache + Redis |
| **Logged in** | Lower | Correct personalized UX | **Dynamic SSR/CSR** + TanStack Query (data cache only) |
**Rule of thumb**
> If the response depends on session / JWT / user id → **never** put it in Full Route Cache (shared HTML).
>
> If the response is the same for every guest → **prefer ISR** (shared HTML) with a short `revalidate`.
Guests browse nationwide events, city landings, event detail, SEO pages.
Logged-in users use profile, bookings, chats, my-events — speed of first HTML
byte is less important than correctness.
---
## 2. Cache layers (top to bottom)
```
Browser
└─ TanStack Query (logged-in + client refinements) ← per-tab, in-memory
Nginx (host edge)
└─ proxy_cache for guest HTML document GETs ← shared across guests (P2)
Next.js server
└─ Full Route Cache (ISR / static HTML) ← shared across guests
└─ Data Cache (`fetch` + `next.revalidate`) ← shared across all SSR
Backend (NestJS)
└─ Redis (`PublicCacheService`) ← shared across all API callers
PostgreSQL
└─ source of truth
```
### 2.1 Full Route Cache (HTML)
- **What:** Pre-rendered HTML for a route segment, reused for many requests until `revalidate` expires.
- **Who benefits:** Guests hitting high-traffic public routes.
- **What breaks it:** Dynamic APIs in that segment — `cookies()`, `headers()`, `searchParams` (unstable), `noStore()`, `cache: 'no-store'`.
- **Our choice:** Guest public pages use `export const revalidate = N`. Logged-in app surfaces stay dynamic.
### 2.2 Data Cache (Next `fetch`)
- **What:** Cached JSON responses from `fetch(url, { next: { revalidate: N } })`.
- **Who benefits:** Every SSR request (guest ISR rebuilds and dynamic logged-in pages).
- **Independent of** per-user HTML: even when HTML is rebuilt per request, **fetch results are shared** across users until TTL.
Implemented in `lib/seo/serverApi.ts``publicGet()`.
### 2.3 TanStack Query (browser)
- **What:** In-memory client cache after hydration; keys from `queries/consumerKeys.ts`.
- **Who benefits:** Logged-in flows, tab keep-alive, soft navigation, filter changes.
- **Not** a substitute for guest HTML ISR — guests often leave before hydration matters for repeat visits.
See [`consumer-caching.md`](./consumer-caching.md).
### 2.4 Backend Redis
- **What:** `PublicCacheService.getOrSet()` for `@Public()` discovery endpoints.
- **Namespaces:** `discovery-cities`, `discovery-categories`, `discovery-home-feed`, etc.
- **TTL:** Reference data = permanent until admin invalidation; home-feed = **30s**.
Even if Next Data Cache misses, Redis avoids hitting Postgres on every API call.
`PublicCacheService` protects cache misses at two levels: an in-process
single-flight map and a short Redis `SET NX` lease shared by all backend
instances. The lease owner double-checks the cache before calling the factory;
waiters consume its result, with a bounded fail-open timeout. Cache writes are
conditional on the namespace version observed before the factory ran, so a
slow request cannot repopulate an invalidated generation.
Permanent-entry invalidation is one atomic Redis Lua operation: it deletes the
old-generation key and advances the namespace version together. Do not replace
it with a separate `DEL` followed by `INCR`; that ordering can leave a
concurrently rewritten, non-expiring orphan key.
### 2.5 Nginx edge HTML cache (P2)
Guest **document** GETs for ISR routes are cached on the VPS Nginx layer
**before** Next.js. This is the fastest layer for repeat guest traffic.
| Item | Value |
| ----------------- | ------------------------------------------------------------------ |
| Config | `deploy/nginx/conf.d/ghabilee-public-html-cache.conf` |
| Locations | `deploy/nginx/snippets/ghabilee-public-html-locations.conf` (prod) |
| Install script | `scripts/nginx-install-public-html-cache.sh` |
| Cache dir | `/var/cache/nginx/ghabilee_public_html` |
| Diagnostic header | `X-Ghabilee-Cache: HIT \| MISS \| BYPASS \| …` |
**Cached paths (GET/HEAD only)**
| Path pattern | Edge TTL | Matches Next ISR |
| ------------------------------------------------------------- | -------- | ---------------- |
| `/` | 60s | 60s |
| `/e/[slug]` | 60s | 60s |
| `/city/**` | 300s | 300s |
| `/category/**` | 300s | 300s |
| `/blog/**` | 300s | 300s |
| `/about`, `/contact`, `/become-a-host` | 1d | 1d (`86400s`) |
| `/faq`, `/terms`, `/privacy`, `/refund-policy`, `/host-guide` | 1d | 1d (`86400s`) |
**Never edge-cached (bypass rules)**
- Non-GET/HEAD, WebSocket `Upgrade`
- Next.js RSC / soft-nav (`RSC` header, `_rsc=` query, `Next-Router-State-Tree`, `Accept: text/x-component`)
- All other routes (`/profile`, `/api`, `/_next/*` via catch-all) — unchanged
Logged-in users may receive cached **guest** HTML for these URLs; client-side
auth refine is intentional (see §1).
**Next.js `Cache-Control`:** ISR guest pages still arrive from Next with
`private, no-store` because `app/layout.tsx` reads CSP nonce headers. Nginx
uses `proxy_ignore_headers Cache-Control` on these locations so edge TTL is
controlled by `proxy_cache_valid`, then rewrites browser `Cache-Control` with
`s-maxage` aligned to ISR.
**CSP nonce note:** Edge-cached HTML reuses the same nonce body+header pair for the cache TTL. This is an
accepted trade-off for guest throughput; do not edge-cache personalized routes.
Static assets (`/_next/static`, `/uploads/`): long `Cache-Control` in
`deploy/nginx/ghabile.conf` (separate from this HTML zone).
**VPS rollout** (after merging nginx changes):
```bash
cd /opt/ghabilee
git pull
sudo ./scripts/nginx-install-public-html-cache.sh
sudo cp deploy/nginx/ghabile.conf /etc/nginx/sites-available/ghabilee
sudo nginx -t && sudo systemctl reload nginx
curl -sSI https://ghabilee.ir/ | grep -i x-ghabilee-cache # expect MISS then HIT
```
---
## 3. Route inventory
### 3.1 Guest — ISR (shared HTML)
| Route | File | `revalidate` | Notes |
| ------------------------- | ------------------------------------- | ------------ | ------------------------------------------------------------------------------ |
| `/` home | `app/(consumer)/page.tsx` | **60s** | Nationwide `home-feed` SSR; **no `cookies()`** on this page |
| `/e/[slug]` event landing | `app/(seo)/e/[slug]/page.tsx` | 60s | Hard load + SEO; soft-nav uses client overlay |
| `/city/[slug]` | `app/(seo)/city/[slug]/page.tsx` | 300s | |
| `/category/**` | `app/(seo)/category/**` | 300s | |
| `/blog/**` | `app/(public)/blog/**` | 300s | |
| `/about` | `app/(public)/about/page.tsx` | **86400s** | Static marketing copy |
| `/contact` | `app/(public)/contact/page.tsx` | **86400s** | Static shell; form POST is client/API |
| `/become-a-host` | `app/(public)/become-a-host/page.tsx` | **86400s** | Static marketing copy |
| `/faq`, `/terms`, … | `app/(public)/[infoPage]/page.tsx` | **86400s** | `PUBLIC_PAGE_SLUGS` in `content/publicPages.ts`**sync nginx regex** (below) |
**Static info pages (`PUBLIC_PAGE_SLUGS`) — keep nginx in sync**
`app/(public)/[infoPage]/page.tsx` only generates routes for slugs in
`PUBLIC_PAGE_SLUGS` (`frontend/content/publicPages.ts`). Nginx does **not**
read that list — the 1-day edge-cache regex is **hard-coded** in:
- `deploy/nginx/snippets/ghabilee-public-html-locations.conf` (production)
- `deploy/nginx/snippets/ghabilee-public-html-locations.dev.conf` (staging)
When you **add or rename** a slug in `PUBLIC_PAGE_SLUGS`, you must also update
the alternation in both files, for example:
```nginx
location ~ ^/(?:about|contact|become-a-host|faq|terms|privacy|refund-policy|host-guide|NEW-SLUG)$ {
```
Then on the VPS: copy snippets + `nginx -t && systemctl reload nginx` (see §2.5).
Forgetting this step means the new page works in Next but **misses edge HTML cache**.
Dedicated routes `/about`, `/contact`, `/become-a-host` are listed explicitly in
that regex — they are not driven by `PUBLIC_PAGE_SLUGS`.
| `/u/[slug]` | `app/(public)/**` | 300s | |
### 3.2 Guest — dynamic by design
| Surface | Why dynamic |
| --------------------------------------------- | ---------------------------------------------------------------- |
| Soft-nav event overlay `@modal/(...)e/[slug]` | Client intercept; not a full document navigation |
| `TrafficAttributionBeacon` | Wrapped in `<Suspense>` on home; attribution POST is client-side |
### 3.3 Logged-in — dynamic + TanStack Query
All under `app/(consumer)/` except the **guest-shared** home ISR shell:
- `/profile`, `/bookings`, `/chats`, `/my-events`, `/identity`, …
- Host wizard `app/events/**` (uses `cookies()` for token)
- After login, home **refines** feed client-side (`ConsumerHomeDiscovery` + `useViewerDiscoveryCityId`)
**Do not** add `export const revalidate` to these routes.
### 3.4 Root layout caveat
`app/layout.tsx` reads `headers()` for CSP nonce → opts the **root layout** into
dynamic rendering. **Child segments can still use ISR** when they export
`revalidate` and avoid `cookies()` / `headers()` in their own segment. This is
why `/e/[slug]` ISR works today.
**Agents:** do not remove the root `headers()` call without a security review.
Fix caching at the **page segment** that serves guests, not by weakening CSP.
---
## 4. Home page (`/`) — detailed contract
### 4.1 SSR payload (same for every guest)
Server component `app/(consumer)/page.tsx` fetches in parallel:
1. `fetchDiscoveryCities()` — Data Cache 5 days
2. `fetchDiscoveryCategories()` — Data Cache 5 days
3. `fetchDiscoveryHomeFeed()`**nationwide** (no `cityId`), Data Cache 60s
Passed as props to `ConsumerHomeDiscovery`:
- `initialCategories`, `initialCities`, `initialHomeFeed`, error props
### 4.2 Client refinements
`ConsumerHomeDiscovery` (`app/(consumer)/_components/ConsumerHomeDiscovery.tsx`):
| Concern | Guest | Logged in |
| ----------------------------- | ---------------------------------------------------------------- | ---------------------- |
| Popular + category feed scope | Nationwide (`feedCityId = undefined`) | Profile city when set |
| Feed query | `placeholderData` from SSR nationwide feed while city feed loads | Same pattern |
### 4.3 Revalidate alignment
| Layer | TTL |
| ------------------------------------- | --------------------------- |
| Page ISR (`export const revalidate`) | 60 |
| `fetchDiscoveryHomeFeed` Data Cache | 60 |
| Backend Redis `discovery-home-feed` | 30 |
| TanStack `discoveryHomeFeedStaleTime` | see `services/discovery.ts` |
When debugging stale home events, check all three layers plus admin event publish.
---
## 5. Event detail — two entry paths
| Entry | Path | Caching |
| ---------------------------- | -------------------------------- | -------------------------------------------- |
| Hard load / share link / SEO | `(seo)/e/[slug]/page.tsx` | ISR 60s + `fetchPublicEventLandingBootstrap` |
| Soft-nav from home shell | `@modal/(...)e/[slug]` intercept | Client fetch; overlay UI |
Agents adding event UI must consider **both** paths or document which is in scope.
---
## 6. What agents must / must not do
### Must
- Keep **guest marketing data** on `@Public()` API routes with Redis caching.
- Use `lib/seo/serverApi.ts` for server components — not `@/config/axios`.
- Use `export const revalidate` on new **guest** indexable landings.
- Gate TanStack `initialData` from SSR to the **exact** key the server fetched (see `ConsumerHomeDiscovery` `useSsrFeed` guard).
- Invalidate backend discovery cache when admin mutates cities/categories/events (existing `invalidatePermanentEntry` patterns).
- When changing `PUBLIC_PAGE_SLUGS`, update nginx static-marketing regex in both `ghabilee-public-html-locations*.conf` files (§3.1).
### Must not
- Call `cookies()` or `headers()` in guest ISR pages to read preferences (city, locale, A/B). Read them **client-side** instead.
- Use `cache: 'no-store'` on public discovery `fetch` without explicit approval.
- Assume Data Cache = HTML Cache — they are separate.
- Add Full Route Cache to `/profile`, `/bookings`, or any JWT-scoped page.
### When adding a new public page
1. Classify: guest-shared vs session-personalized.
2. Guest-shared → `revalidate` + `serverApi` fetch with TTL (or static copy → `86400` for marketing/legal).
3. Session-personalized → dynamic + consumer query hooks.
4. Update the **route inventory** table in this doc (section 3).
5. **If the page is a new `[infoPage]` slug:** append the slug to the nginx
static-marketing `location` regex in **both**
`ghabilee-public-html-locations.conf` and `ghabilee-public-html-locations.dev.conf`
(see §3.1 — `PUBLIC_PAGE_SLUGS` sync). Reload nginx on the VPS after deploy.
---
## 7. Implementation priorities
| Priority | Work | Status |
| -------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| **P0** | ISR `/` — remove `cookies()` from `page.tsx`, `revalidate = 60` | Done (2026-08-31) |
| **P1** | Ensure guest event views hit ISR route on hard load (already `/e/[slug]`) | Done |
| **P2** | Nginx edge HTML cache for guest ISR routes | Config in repo — **activate on VPS** via `nginx-install-public-html-cache.sh` |
---
## 8. Debugging checklist
**"Guest home shows old events"**
1. Page ISR age (up to 60s)
2. Next Data Cache for `discovery/home-feed` (60s)
3. Redis `discovery-home-feed` (30s)
4. Event discoverability flags in DB
**"Second guest isn't faster"**
1. Confirm route is ISR (`revalidate` exported, no `cookies()` in page)
2. Check `X-Ghabilee-Cache` header — `HIT` means Nginx edge served HTML
3. Check server logs — Next miss should not call API if Data Cache warm
4. Distinguish TTFB (HTML) vs client hydration
**"Logged-in user sees wrong city on home"**
1. `useViewerDiscoveryCityId` / profile `cityId`
2. `consumerKeys.discoveryHomeFeed(feedCityKey)` — not SSR `initialData` on city key
3. `placeholderData` vs `initialData` rules in `ConsumerHomeDiscovery`
---
## 9. Glossary
| Term | Meaning |
| ---------------------- | --------------------------------------------------------------------- |
| **ISR** | Incremental Static Regeneration — shared HTML with timed revalidation |
| **Full Route Cache** | Next.js cache of rendered route output (HTML + RSC payload) |
| **Data Cache** | Next.js cache of `fetch` responses |
| **Nationwide feed** | `home-feed` without `cityId` — marketing default for guests |