Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
345 lines
18 KiB
Markdown
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 |
|