Status: Authoritative Step-2 design. Consolidates the 7 domain sections, deduplicated and made contradiction-free, with all reviewer high/medium fixes applied. Stack verified against /root/projects/creatisoul-website (Payload ^3.84.1, Next 16.2, db-postgres, Lexical, Tailwind v4) and the 6-page mockups.
Project: Rebuild globalrealtor.co.in (Drupal 7, EOL, ~900 URLs, ~500 property nodes, some hacked/spam) into Payload CMS 3 + Next.js App Router. A 30-year Bangalore realtor (founder Vaseem Khan; CreatiSoul is the dev agency).
0Decisions resolved up front reviewer fixes applied
These ten decisions are binding for the whole document; every later section conforms to them. They resolve the cross-section contradictions the reviewer caught.
| # | Question | Decision | Why |
|---|---|---|---|
| D1 | Status / publish model | One status select on ALL public collections. No versions.drafts, no _status, no trash. | Matches the verified reference Pages.ts. Removes the dual source-of-truth. "Unpublish" = set status away from a visible value (a normal update, which Managers have). Hard-delete is the only owner-gated destructive op. |
| D2 | Public visibility gate | One canonical publishedPropertyWhere(): visible when status ∈ {available, booked, sold, ongoing} AND publishedAt ≤ now. draft → 404. | Sold/booked pages stay live (200) with a badge — SEO equity preserved; sold may be noindex per toggle. Divergent inline clauses deleted. |
| D3 | Locations model | ONE self-referencing locations collection (kind: region|locality, self-ref parent, legacyTermId). | More downstream consumers depend on it; the two-collection rationale was presentational. Migration §3 rewritten to import into locations. |
| D4 | Property location shape | Nested location group with a single geo point field ([lng,lat]). Dotted-path queries are canonical. | The dotted-path code is the most pervasive. JSON-LD + WKT POINT(lng lat) parse read the one geo point consistently. |
| D5 | Relationship slug | Every location relationTo is 'locations' (never 'localities'). | Eliminates the boot-time "unknown collection" config error. |
| D6 | Property slug uniqueness | Composite unique (region, locality, slug) — NOT global unique. | Lets identical tail slugs (3bhk-apartment) coexist across localities, preserving D7 URLs byte-for-byte (the #1 risk). |
| D7 | Redirect status | 308 (permanent) via proxy.ts / RSC permanentRedirect. Assertions/monitors accept 301-or-308. | Next App Router can't emit a true 301 from an RSC; Google treats 308 == 301. proxy.ts is a NEW build item de-risked by an early spike. |
| D8 | psf sort | Dropped from sort enums (mockup has only Relevance / Price↑ / Price↓ / Newest / Area). pricePerSqft kept for display/filtering. | Stops scope drift; matches the approved mockup. A ₹/sq.ft sort is a future product decision. |
| D9 | Requirement public create | create: () => false at collection level. The Server Action is the only ingress (Local API overrideAccess). | Closes the raw REST/GraphQL flood vector that produced D7 spam; all spam defenses become unbypassable. |
| D10 | CDN cache coherence | Launch blocker: Cloudflare HTML uncached (static assets only) + a CF cache-purge call in the same afterChange hooks. | A stale "available" price on a sold flat is a business/legal problem for a lead-first portal. |
1System Architecture
GCR is a lead-first portal: read-heavy, ~500 listings, no AI/video compute. Server-first (RSC) with selective client islands, Payload CMS 3 inside the same Next.js 16 process, Postgres, media on Garage S3.
Architecture / data-flow diagram
Cloudflare (orange-cloud)
HTML: uncached · static assets: cached at edge
+ cache-purge API hit on content change (D10)
│
▼
Traefik v3.6 (coolify-proxy) · Let's Encrypt
│
┌──────────────────────────┴───────────────────────────┐
│ Coolify App "globalrealtor" (Node 22-alpine) │
│ Next.js 16 (standalone, Turbopack) + Payload 3.84 │
│ (frontend) RSC pages ──┐ ┌── (payload) /admin │
│ proxy.ts (308 redirects)│ │ REST · GraphQL │
│ ▼ ▼ │
│ getPayload() Local API · React.cache │
└───────┬───────────────────────────────┬───────────────┘
│ │
Postgres (pgvector/pg16) Garage S3 s3.csoul.cloud
globalrealtor-db bucket globalrealtor-prod
localproxy 127.0.0.1:5434 (forcePathStyle, region "garage")
│
prodMigrations on boot (Redis: NOT v1 — add a DB index
(push:true is a prod no-op) only when notifications need a queue)
Request trace — /north-bangalore/hennur/lvs-lavender-3bhk
1. Cloudflare edge → MISS on HTML (uncached) → origin
2. proxy.ts checks the cached redirect map → miss → pass through
3. Router: not a reserved literal → [...path]/page.tsx (3 segments)
4. generateMetadata + body both call resolvePath(path) (React.cache → ONE execution)
length 3 → property lookup w/ locality+region constraint + publishedPropertyWhere() → kind:'property'
5. Render detail + inject RealEstateListing + BreadcrumbList JSON-LD
6. Cached with tags property:<id> ; served via ISR
7. Later admin edit → revalidateTag('property:<id>') + listings:locality:<id> + CF purge (D10)
Stack & rendering strategy
- Next.js 16.2 (App Router,
output:"standalone", Turbopack), React 19. Middleware issrc/proxy.ts;params/searchParams/cookies()/headers()are async; admin undersrc/app/(payload)/. - Payload CMS 3.84 — db-postgres, richtext-lexical, storage-s3, sharp.
- Tailwind v4, Lucide React, Framer Motion. Brand green
#00b74a/ orange#ff9800; Poppins + Inter vianext/font(self-hosted). - Node 22-alpine, 3-stage Dockerfile with
npm ci --include=dev(Coolify injectsNODE_ENV=productionwhich otherwise drops devDeps and breaks the build).
Hybrid SSG + ISR + tag revalidation; only /find-property and sitemaps are force-dynamic.
| Route | Strategy | Revalidation trigger |
|---|---|---|
/ home | ISR + tag home | SiteSettings / featured Property change |
Property /[r]/[l]/[slug] | SSG over available + featured only (long tail via ISR) | Property.afterChange/afterDelete → tags |
| Locality / Region | SSG + ISR | Locations.afterChange + matching Property change |
CollectionPage /[slug] | ISR, tag collection:<id> | CollectionPage.afterChange + matching Property change |
| Services / company-profile / contact | SSG + ISR, tag page:<slug> | respective afterChange |
/find-property | force-dynamic | per-request |
| sitemaps | force-dynamic (try/catch) | per-request, reads cached counts |
Infra
Co-host on the CreatiSoul Coolify VPS (62.72.56.130, Coolify v4, Traefik v3.6, Garage v2.1). New Coolify App globalrealtor + own Postgres globalrealtor-db + host socat localproxy on 127.0.0.1:5434 + dedicated Garage bucket globalrealtor-prod. No Redis for v1. Schema via prodMigrations (on boot; push:true is a prod no-op). Healthcheck GET http://127.0.0.1:3000/admin (IPv4). Marginal cost ≈ $0/mo.
d7:import media phase: measure real footprint from D7 file_managed.filesize, bump the layout (garage layout assign <node> -c 80G && garage layout apply, verify with garage layout show). Off-host media copy = Hostinger VM snapshot + the D7 originals.2Payload CMS Structure
Cross-cutting conventions
- Slug fields:
text, required, index+ a sharedformatSlughook. Property.slug is notunique— uniqueness is the composite(region, locality, slug)(D6). Other slugs stay single-columnunique. - Status (D1): every public collection uses a plain
statusselect +publishedAt-backfill hook. Noversions/_status/trashanywhere.
// src/access/index.ts
export const isOwner: Access = ({ req }) => roleOf(req.user) === 'owner'
export const isStaff: Access = ({ req }) => ['owner','manager'].includes(roleOf(req.user) ?? '')
export const readPublishedOrStaff: Access = ({ req }) =>
req.user ? true : publishedPropertyWhere() // anon → published-only
2.1 The canonical visibility function (D2)
// src/lib/property-query.ts — the ONE source of truth
export const VISIBLE_STATUSES = ['available','booked','sold','ongoing'] as const
export function publishedPropertyWhere(extra?: Where): Where {
const base = { and: [
{ status: { in: [...VISIBLE_STATUSES] } }, // draft → 404
{ publishedAt: { less_than_equal: new Date().toISOString() } },
] }
return extra ? { and: [base, extra] } : base
}
Product rule: sold/booked stay at HTTP 200 with a status badge (preserves backlinks), may carry noindex via the per-listing toggle. Every front-end and collection-builder where-clause calls this function; the old inline _status: published and status in ['available','booked'] variants are deleted.
2.2 Property — tabs & conditional logic
Tabs: Listing · Pricing · Specs · Amenities · Location · Media · SEO & Migration. admin.condition(data) keyed on type/purpose mirrors the mockup's data-show-for / data-show-for-purpose.
| field | type | notes (Listing tab) |
|---|---|---|
listingBy | select | owner|broker|builder (default broker) |
purpose | select | sale|rent|lease|pg — drives pricing + PG fields |
type | select | apartment|serviced|house|villa|plot|bda — drives every conditional |
slug | text | index, formatSlug; composite-unique (region,locality,slug) |
status | select | draft|available|booked|sold|ongoing — the single status (D1) |
featured | checkbox | index, sidebar |
verified | checkbox | gates the "Verified by GCR" badge (mockup); owner/manager-set |
Pricing (conditional on purpose)
// SALE
{ name: 'price', type: 'number', admin: { condition: (d) => d?.purpose === 'sale' } }
{ name: 'priceOnRequest', type: 'checkbox' } // migration sets for "Negotiable"/"Call"
{ name: 'pricePerSqft', type: 'number', admin: { readOnly: true } } // display/filter only — NOT a sort (D8)
// RENT / LEASE / PG
{ name: 'monthlyRent', admin: { condition: (d) => ['rent','lease','pg'].includes(d?.purpose) } }
{ name: 'deposit' }, { name: 'maintenance' }
// PG-only
{ name: 'sharingType' }, { name: 'preferredTenants', hasMany: true }
Specs — reviewer completeness fixes applied
// BUILT-UNIT GROUP (apartment|serviced|house|villa)
unit: { bedrooms, bathrooms, furnishing, facing, propertyAge, availability,
transaction, ownership, areaSqft /*built-up*/, carpetAreaSqft /*ADDED, mockup*/ }
floor / totalFloors // apartment|serviced only
// POSSESSION — select group, NOT a free date (D-fix, mockup is a SELECT)
possession: { state: 'immediate'|'3m'|'6m'|'dated', possessionDate? (when dated) }
// PLOT GROUP (plot|bda) — reviewer fixes:
plot: { plotType: 'residential'|'commercial'|'agricultural', // ADDED
plotDimensions, plotAreaSqft, roadWidth, cornerPlot,
facing: 8-dirs + 'corner', // 'corner' ADDED
khata: 'A'|'B'|'E', // E-Khata ADDED
brand: 'none'|'BDA' }
bdaSanctionNo // bda only
bdaVerified: checkbox (default false) // ADDED — gates the "BDA Approved" badge truthfully
Full type×purpose matrix re-walked: PG fields show for any built type incl. independent-house PG; monthlyRent shows for plot+rent/lease (rare but legal); plots have no BHK/floor/amenities; serviced+pg and villa+pg valid.
Location (nested group — D4)
location: { region (rel 'locations', kind=region), locality (rel 'locations', kind=locality, parent=region),
street, city (default Bangalore), pincode,
geo: 'point' // [lng,lat] — single source for JSON-LD + map pin,
nearby[]: { kind, name, distanceKm } }
Amenities = fixed select hasMany of the 12 (built-units only); PropertyType = the type enum + a static config/propertyTypes.ts. Both are closed lists — collections rejected (editable labels would desync from admin.condition strings and break URL preservation).
SEO & Migration tab: metaTitle, metaDescription, ogImage, noindex, legacyNodeId (unique), legacyAliases[] (each → 308 Redirect).
2.3 Locations — ONE self-referencing collection (D3)
slug: 'locations'; access: { read: allowPublic, create/update: isStaff, delete: isOwner }
fields: kind (region|locality), name, slug (unique),
parent (rel 'locations', kind=region, shown when kind=locality),
intro, connectivity[], geo (point), heroImage, legacyTermId (unique), legacyAliases[]
Integrity guard: a region has no parent; a locality must have a region parent. Seed 6 regions + 70+ localities. Compound index (parent, slug).
2.4 CollectionPage builder (replaces D7 Views)
Tabs: Page · Query · SEO · Legacy URLs. All relationTo are 'locations' (D5). purposes/types/bhk are select hasMany closed enums.
Query tab: locationScope (all-bangalore|region|area|areas) → region | areas[] (conditional),
purposes[], types[], priceMin/Max, bhk[], featuredOnly,
defaultSort: featured|newest|price-asc|price-desc|area // psf DROPPED (D8),
featuredOrder[] (pin to top)
SEO tab: isIndexable (master), indexable (cached boolean §4), metaTitle/desc, canonicalUrl, ogImage
One resolution engine — whereFromCollectionPage() — is called by both the renderer and the admin live preview, so the count preview equals what visitors see. It intersects with publishedPropertyWhere() (D2) and uses dotted-path location clauses (D4).
locationScope | clause |
|---|---|
| all-bangalore | (none) |
| region | { 'location.region': { equals: id } } (region denormalized → one indexed equality) |
| area | { 'location.locality': { equals: areas[0] } } |
| areas | { 'location.locality': { in: areas } } — OR-semantics |
Price purpose-aware (sale→price, rent-like→monthlyRent, mixed→suppressed + warning). BHK 5 = "4+". Live preview endpoint /api/collection-pages/preview runs the same builder (count + 5-row sample + indexability chip + warnings).
2.5–2.10 Requirement · Service · Media · Redirect · Users · SiteSettings
- Requirement (
requirements):create: denyAll(D9).kind(requirement|enquiry|alert) +intent; conditional lookingFor/haveProperty groups; triage (status, assignedTo, internalNotes, priority); provenance (sourcePage, utm,ipHashsalted, consent/consentAt); notify audit.afterChange→dispatchNotification. - Service (
services): title, slug, summary, icon, hero/vaseem images, body, features[], processSteps[], faqs[], order,statusselect + publishedAt, SEO.delete: isOwner. - Media (
media): S3/Garage via storage-s3 (bucketglobalrealtor-prod, forcePathStyle, region "garage"); sizes thumbnail/card/feature/og;altrequired,legacyFid(unique),sha256(dedup). No local volume. - Redirect (
redirects): owner-only;from(unique),to,typedefault 308 (D7),gone(spam → 410).afterChange→revalidateTag('redirects')busts the proxy map. - Users (
auth): owner|manager, field-levelroleaccess (no self-escalation), 8h tokens, lockout. Admin hardening: Cloudflare Access + 2FA for owner, Redis-rate-limited login, configured forgot-password provider, rotate all D7-era creds. - SiteSettings (global):
read: allowPublic, update: isOwner. Tabs Contact / Social / Homepage / Channel Partners / Navigation / Leads&Notifications / SEO Defaults (incl.minIndexableResultsdefault 3).
2.11 Roles & access — the no-delete guarantee (D1-corrected)
delete: isOwner, enforced at the operation layer (Forbidden across UI / REST / GraphQL; overrideAccess only for server-side Local API). There is no trash loophole because trash is not enabled. The old _status/trash narrative is dropped — it relied on versions.drafts which we do not use. "Unpublish" = status set away from a visible value (a normal Manager update).| Collection | create | read | update | delete |
|---|---|---|---|---|
| users | isOwner | isOwner | isOwner / self | isOwner |
| properties | isStaff | readPublishedOrStaff | isStaff | isOwner |
| collection-pages | isStaff | allowPublic | isStaff | isOwner |
| services | isStaff | allowPublic | isStaff | isOwner |
| locations | isStaff | allowPublic | isStaff | isOwner |
| media | isStaff | allowPublic | isStaff | isOwner |
| requirements | denyAll (D9) | isStaff | isStaff | isOwner |
| redirects | isOwner | allowPublic | isOwner | isOwner |
| site-settings | — | allowPublic | isOwner | — |
SiteSettings / Users / Redirects = owner-only (resolves the brief's open question — bar Manager from site-config: yes). System nav items use admin.hidden for managers (UX); access is the real enforcement. Reports = owner-only /admin/reports.
3Drupal-7 Migration Plan
Two-stage pipeline with a stable JSON intermediate, idempotent and re-runnable.
[D7 MySQL] --(1 extract)--> [normalized JSON snapshot + media blobs] --(2 import)--> [Payload Local API → Postgres + Garage S3]
read-only copy /migration/snapshot/*.json idempotent upsert by legacy* keys
- Stage 1 (extract): Node
mysql2against a read-only mysqldump restore (never production — zero PHP execution on a hacked site). Self-discovering viafield_config/field_config_instance— captures everything; unmapped →_raw. Measurefile_managed.filesizehere to size the Garage bump. - Stage 1.5 (transform): clean + spam-filter + taxonomy-map + URL-map →
import-ready.json. - Stage 2 (import): Payload Local API, upsert by
legacyNodeId/legacyTermId/legacyFid.
D7 field machine names are inferred from the brief + mockups. The extractor prints the discovered field_config list — treat any mismatch as an open question, not a guess.
Field mapping (Property, abridged)
| D7 source | Payload field | Transform |
|---|---|---|
node.nid | legacyNodeId (unique) | natural key |
url_alias | slug + legacyAliases[] | verbatim; composite-unique (D6) |
node.status | status | 1→available, 0→draft |
field_property_for | purpose | for-sale→sale, pg→pg… |
field_property_category | type (+ plot.brand) | term-map; BDA Site→bda + brand BDA |
field_price | price OR monthlyRent | route by purpose; Lakh/Crore expand; "Negotiable"→null + priceOnRequest |
field_super_builtup / field_carpet | unit.areaSqft / unit.carpetAreaSqft | strip units |
field_possession | possession.{state,date} | "Immediate"→immediate, "By Dec 2026"→dated+date (select, not free date) |
field_khata | plot.khata | A/B/E (E-Khata preserved) |
| BDA sanction | bdaSanctionNo; bdaVerified=false | badge hidden until staff confirm |
field_location (taxref) | location.locality (+ derived region) | term → locations doc |
geofield WKT POINT(lng lat) | location.geo point | parse → [lng,lat] (D4) |
field_images → file_managed | gallery[] + coverImage | alt from alt column; sha256 dedup |
Taxonomy: category→type, location→locations (parent=0 → region; child → locality+parent), brand→plot.brand, amenities→12-key synonym map. Spam exclusion = WHITELIST: content-type gate + required-valid-fields + heuristic score (pharma/casino regex, outbound-link count) + explicit known-bad list (/node/4204); every exclusion → excluded.jsonl.
URL preservation — 308 permanent (D7)
Every live non-spam alias keeps working: served natively at the identical path, or a 308 (Google == 301). Property paths byte-identical (composite-unique slug makes this possible even when tail slugs collide).
- Property: composed
/region/locality/slug; equals legacy → native, else 308. Diff assertion flags mismatches (must be zero non-redirected before cutover). - Collection aliases (D7 Views) → seed one CollectionPage per legacy URL (slug = exact path,
managedSource:'migrated'). Migrated + hand-built share one renderer/sitemap. - Static verbatim;
/contact-us→ 308/contact./node/NNNN→ mapped path; hacked nodesgone:true→ 410.
Importer: npm scripts d7:extract / transform / import / import:dry / validate; dependency order Locations → Media → Properties → Services → CollectionPages → Redirects → SiteSettings; { context:{ skipRevalidate:true } } during bulk load; batch-50 + checkpoint resumability; schema (incl. composite index) via migrate:create → prodMigrations.
Validation gate (report.md): aliases not covered by a native route or redirect = MUST be zero; URL-parity mismatches = zero non-redirected; plus counts (imported / excluded / quarantined / media / redirects) and integrity checks.
Cutover & rollback
Staging at gr.csoul.cloud (indexing blocked): extract → transform (review with Vaseem) → import:dry → import → validate (zero uncovered aliases) → manual QA → Screaming Frog crawl. Cutover (D7 stays live on legacy.): freeze → fresh dump → re-run (idempotent) → add prod domains → activate proxy map → smoke-test top redirects (accept 308-or-301 → 200) → submit new + legacy sitemaps.
pg_dump + Garage inventory.Post-cutover SEO (weeks 1–8): GSC Coverage / URL Inspection; daily redirect test asserting 301-or-308 → 200 (no chains/loops); 404 logging → add redirects; baseline + weekly clicks/impressions on top 50 URLs; Rich Results Test on JSON-LD.
4Thin / duplicate-content control (cached & stable)
Index a CollectionPage when isIndexable AND result count ≥ minIndexableResults (default 3). Reviewer fix: do NOT count live in the force-dynamic sitemap (an N-query fan-out that can 500). Instead:
- Store denormalized
liveResultCount+indexableboolean per CollectionPage, recomputed by a tag-revalidated job whenever a matching Property changes (we already bumplistings*tags) and in the page's ownafterChange. - The sitemap filters on the stored
indexableboolean — one indexed query, cheap and stable. - Render-time
noindexstays as a safety net but reads the cached count. - Hysteresis: only flip to
noindexafter the count is below the minimum for a sustained period (avoids index thrash).
Canonical self by default; canonicalUrl folds near-duplicates; ?sort=/?page= never in the canonical; pages 2+ noindex,follow. Authoring guards: slug-collision hard-block (incl. reserved + locality shapes) + soft duplicate-criteria warning. Unique intro rich text is the positive lever against thinness.
5Front-end routing & search
Two route groups: (payload) (admin + REST/GraphQL) and (frontend). Reserved literal folders (matched before the catch-all): find-property, compare, post-requirement, company-profile, contact, services/[serviceSlug]. Everything else → [...path]/page.tsx catch-all resolver.
Segment resolution (resolvePath, React.cache-wrapped, ≤3 DB queries)
| # | Segments | Try | Match |
|---|---|---|---|
| 1 | 3 | Property | slug==c AND location.locality.slug==b AND location.region.slug==a (composite, D6) |
| 2 | 2 | Locality | kind:locality, slug==b, parent.slug==a |
| 3 | 1 | Region | kind:region, slug==a |
| 4 | 1 | CollectionPage | slug==a (after region) |
| 5 | any | Redirect | from == '/'+segments.join('/') → 308 |
| 6 | — | 404 | notFound() |
Redirect status (D7): hot/known aliases handled in proxy.ts via NextResponse.redirect(to, 308), loading the map through a revalidateTag('redirects')-cached server function (not module-eval TTL — reflects slug changes immediately). Static legacy set can also compile into next.config redirects. RSC permanentRedirect (308) is the safety net. proxy.ts + segmented sitemaps are NEW build items — de-risked by an early spike (Phase 0).
Search (/find-property, force-dynamic)
URL = state; typed parseSearchParams whitelists every param. buildPropertyQuery → Payload where ∩ publishedPropertyWhere() (D2), dotted-path location (D4), purpose-aware price. Sort exactly matches the mockup (D8): Relevance · Price↑ · Price↓ · Newest · Area — no psf. limit=12; facet counts via a single Drizzle GROUP BY cached with tag listings. Map = lazy MapLibre island (view=map only). Compare = localStorage store (max 4) mounted as a layout island; /compare?ids= server-resolves opaque ids, SSR'd, noindex.
6SEO infrastructure
- Sitemaps:
generateSitemaps()index → properties / localities / collections (cachedindexablefilter) / services / static, all force-dynamic + try/catch./find-property+/compareexcluded. - robots.txt: Disallow
/admin,/api,/compare,/find-property?*. - Canonical/noindex: entity pages self-canonical;
/find-propertynoindex,followon any filter param; high-value combos become real indexable CollectionPages. - JSON-LD: layout =
RealEstateAgent(GCR, Vaseem Khan, founded 1995) +WebSiteSearchAction; Property =RealEstateListing(price/floorSize fromunit.areaSqft, rooms fromunit.bedrooms, geo fromlocation.geo); Locality/Region =Place; CollectionPage =CollectionPage+ItemList; Service =Service; FAQ pages =FAQPage; non-home =BreadcrumbList. - CWV:
next/imageremotePatterns fors3.csoul.cloud; fixed aspect-ratios (no CLS);next/fontPoppins/Inter (kills render-blocking Google Fonts link); ~5 client islands.
| Mockup | Route |
|---|---|
| home.html | / |
| search.html | /find-property |
| property.html | /[region]/[locality]/[slug] |
| locality.html | /[region]/[locality] (+ /[region]) |
| compare.html | /compare |
| post-property.html | the admin Property form (posting is admin-only) |
| "Post Requirement" CTA | /post-requirement (public lead form) |
The mockup's "898 of 898" count is illustrative placeholder — the brief says ~500 properties. Do not use 898 as a seed/QA acceptance target.
7Leads, notifications & reports
Three surfaces, one Server Action (submitRequirement, 'use server'): (a) /post-requirement (intent-conditional); (b) property-detail enquiry card + tel:/wa.me primary conversions; (c) "Alert me" save-search (v1 store + manual match; automated matcher deferred).
Submission path (D9): requirements.create = denyAll; the action is the sole ingress, writing via Local API overrideAccess after honeypot → time-trap → ipHash → Redis rate-limit (falls open if Redis down) → Turnstile (config-gated) → zod → create → afterChange dispatch. ('use server' files export only async — keep zod/types in a sibling constants.ts.)
Notifications: Resend for v1 email (fallback Google Workspace SMTP, swappable behind config); recipients from SiteSettings; dispatchNotification() indirection so a BullMQ queue swap is one line. WhatsApp v1 = wa.me click-to-chat deep links; later = BullMQ notify_whatsapp via WhatsApp Cloud API (deferred). Idempotent (key on requirementId + channel); try/catch'd (never rejects the lead write).
Inbox: Payload list view + native filters; status workflow new→contacted→qualified→site-visit→closed-won/lost + spam; assignedTo; internalNotes timeline. Reports: owner-only /admin/reports — leads over time, by intent (buy/rent/sell/rentout), by source/UTM, by status (conversion), popular localities, listing counts, "available with zero enquiries".
PII/DPDP: explicit consent + consentAt; ipHash never raw IP; leads staff-read-only; 404 (not 403) for unauthorized; retention sweep designed-for but deferred; owner-delete = right-to-erasure.
8Phased build roadmap migration-first
| Phase | Goal | Key items | Est. |
|---|---|---|---|
| 0 · Spike & infra | De-risk unknowns | Coolify App + DB + localproxy :5434 + Garage bucket + capacity bump gate; proxy.ts 308 spike; CF HTML-uncached + cache-purge (D10); admin behind CF Access + 2FA | 3–5 d |
| 1 · Payload + migrate + go live | CMS up, migrate everything, cut over with zero SEO loss | All collections (D1–D6); migrations; d7:extract/transform/import/validate; seed Locations; import Properties/Services/static; migrated CollectionPages; Redirects + proxy map; segmented sitemaps; validation gate = zero uncovered aliases; staging QA + crawl; CF flip + rollback | 3–4 wk |
| 2 · Front-end templates | The public site | layout chrome from SiteSettings; home; [...path] resolver; /find-property (D8 sort); property/locality/region/services/company/contact; JSON-LD; next/image+S3; CWV | 3–4 wk |
| 3 · Collection-page builder + polish | Growth tooling | Query tab + whereFromCollectionPage + live preview; cached indexable/liveResultCount + hysteresis (§4); compare tray + /compare; facet counts; map island | 2–3 wk |
| 4 · Admin, reports, leads, WhatsApp, commercial | Operate & grow | Lead Server Action + spam stack + Resend + reports; wa.me v1; retention sweep; later BullMQ notify_whatsapp + alert matcher; commercial type | 2–3 wk |
9Open questions to confirm with the owner
- Sold/booked visibility & indexing: confirm they stay live (200) with a badge for SEO equity; should
soldbenoindex? (Recommended: stay live; noindex optional.) - ₹/sq.ft sort: mockup has none (D8 dropped it). Add a psf sort as a new product decision, or leave out? (Derived
pricePerSqftkept regardless.) - Shared vs dedicated host: confirm co-hosting on the CreatiSoul VPS (shares kernel/Traefik/Coolify — a host incident affects all three sites).
- Email provider: Resend (recommended) vs the client's Google Workspace SMTP? From-address + notification recipients (Vaseem + office)?
- BDA verification source: start imported BDA listings
bdaVerified=false(badge hidden), or map a D7 verification field? (Badge must never show unverified — liability.) - Canonical host: apex or
www(match D7)?/contact-us→ 308/contactacceptable? - Cloudflare control: do we have CF access for the DNS flip, ACME/grey-cloud step, and the cache-purge token (D10)?
- D7 access: read-only mysqldump +
sites/default/files/+ the full live URL list (sitemap + GA/GSC) for migration + the parity gate? - D7 field names: confirm the inferred mapping against the extractor's discovered
field_config. Does D7 track per-listing featured/agent/BDA-verified data to preserve? - Lead routing & auto-reply: route enquiries vs sell-leads differently? Enable the confirmation email to the lead (consent implication)?
- Manager scope: confirm Manager is barred from SiteSettings/Users/Redirects and has no delete anywhere. Future agent/broker roles?
- Privacy / DPDP: privacy-policy content (linked from consent) + retention window (N months for closed-lost/spam)?
- Compare & alerts v1: is automated "new matching listing → notify lead" needed at launch, or is the deferred manual-match alert acceptable?