Global Constructions & Realtors — Rebuild Architecture (Step 2)

Drupal 7 → Payload CMS 3 + Next.js 16 · lead-first Bangalore property portal · est. 1995
Step 2 · Lead Architect

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.

#QuestionDecisionWhy
D1Status / publish modelOne 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.
D2Public visibility gateOne 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.
D3Locations modelONE 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.
D4Property location shapeNested 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.
D5Relationship slugEvery location relationTo is 'locations' (never 'localities').Eliminates the boot-time "unknown collection" config error.
D6Property slug uniquenessComposite 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).
D7Redirect status308 (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.
D8psf sortDropped 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.
D9Requirement public createcreate: () => 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.
D10CDN cache coherenceLaunch 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 is src/proxy.ts; params/searchParams/cookies()/headers() are async; admin under src/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 via next/font (self-hosted).
  • Node 22-alpine, 3-stage Dockerfile with npm ci --include=dev (Coolify injects NODE_ENV=production which otherwise drops devDeps and breaks the build).

Hybrid SSG + ISR + tag revalidation; only /find-property and sitemaps are force-dynamic.

RouteStrategyRevalidation trigger
/ homeISR + tag homeSiteSettings / featured Property change
Property /[r]/[l]/[slug]SSG over available + featured only (long tail via ISR)Property.afterChange/afterDelete → tags
Locality / RegionSSG + ISRLocations.afterChange + matching Property change
CollectionPage /[slug]ISR, tag collection:<id>CollectionPage.afterChange + matching Property change
Services / company-profile / contactSSG + ISR, tag page:<slug>respective afterChange
/find-propertyforce-dynamicper-request
sitemapsforce-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.

Garage capacity gate (prerequisite)
The node is assigned only 10 GB; ~500 galleries × 4 derivatives will exceed it. Before the 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 shared formatSlug hook. Property.slug is not unique — uniqueness is the composite (region, locality, slug) (D6). Other slugs stay single-column unique.
  • Status (D1): every public collection uses a plain status select + publishedAt-backfill hook. No versions/_status/trash anywhere.
// 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.

fieldtypenotes (Listing tab)
listingByselectowner|broker|builder (default broker)
purposeselectsale|rent|lease|pg — drives pricing + PG fields
typeselectapartment|serviced|house|villa|plot|bda — drives every conditional
slugtextindex, formatSlug; composite-unique (region,locality,slug)
statusselectdraft|available|booked|sold|ongoing — the single status (D1)
featuredcheckboxindex, sidebar
verifiedcheckboxgates 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).

locationScopeclause
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, ipHash salted, consent/consentAt); notify audit. afterChangedispatchNotification.
  • Service (services): title, slug, summary, icon, hero/vaseem images, body, features[], processSteps[], faqs[], order, status select + publishedAt, SEO. delete: isOwner.
  • Media (media): S3/Garage via storage-s3 (bucket globalrealtor-prod, forcePathStyle, region "garage"); sizes thumbnail/card/feature/og; alt required, legacyFid (unique), sha256 (dedup). No local volume.
  • Redirect (redirects): owner-only; from (unique), to, type default 308 (D7), gone (spam → 410). afterChangerevalidateTag('redirects') busts the proxy map.
  • Users (auth): owner|manager, field-level role access (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. minIndexableResults default 3).

2.11 Roles & access — the no-delete guarantee (D1-corrected)

Corrected mechanism
The guarantee rests on one chokepoint: 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).
Collectioncreatereadupdatedelete
usersisOwnerisOwnerisOwner / selfisOwner
propertiesisStaffreadPublishedOrStaffisStaffisOwner
collection-pagesisStaffallowPublicisStaffisOwner
servicesisStaffallowPublicisStaffisOwner
locationsisStaffallowPublicisStaffisOwner
mediaisStaffallowPublicisStaffisOwner
requirementsdenyAll (D9)isStaffisStaffisOwner
redirectsisOwnerallowPublicisOwnerisOwner
site-settingsallowPublicisOwner

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 mysql2 against a read-only mysqldump restore (never production — zero PHP execution on a hacked site). Self-discovering via field_config/field_config_instance — captures everything; unmapped → _raw. Measure file_managed.filesize here 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 sourcePayload fieldTransform
node.nidlegacyNodeId (unique)natural key
url_aliasslug + legacyAliases[]verbatim; composite-unique (D6)
node.statusstatus1→available, 0→draft
field_property_forpurposefor-sale→sale, pg→pg…
field_property_categorytype (+ plot.brand)term-map; BDA Site→bda + brand BDA
field_priceprice OR monthlyRentroute by purpose; Lakh/Crore expand; "Negotiable"→null + priceOnRequest
field_super_builtup / field_carpetunit.areaSqft / unit.carpetAreaSqftstrip units
field_possessionpossession.{state,date}"Immediate"→immediate, "By Dec 2026"→dated+date (select, not free date)
field_khataplot.khataA/B/E (E-Khata preserved)
BDA sanctionbdaSanctionNo; bdaVerified=falsebadge hidden until staff confirm
field_location (taxref)location.locality (+ derived region)term → locations doc
geofield WKT POINT(lng lat)location.geo pointparse → [lng,lat] (D4)
field_imagesfile_managedgallery[] + coverImagealt 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 nodes gone: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:createprodMigrations.

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.

Domain flip & rollback
Domain is behind Cloudflare. Temporarily grey-cloud so Traefik gets the LE cert (CF can block ACME HTTP-01), then re-enable orange-cloud. Flip = point CF at 62.72.56.130, low TTL → seconds. Rollback = re-point CF to the untouched D7 origin (103.21.59.198); keep D7 read-only ≥90 days. Pre-flip: fresh VM snapshot + manual 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 + indexable boolean per CollectionPage, recomputed by a tag-revalidated job whenever a matching Property changes (we already bump listings* tags) and in the page's own afterChange.
  • The sitemap filters on the stored indexable boolean — one indexed query, cheap and stable.
  • Render-time noindex stays as a safety net but reads the cached count.
  • Hysteresis: only flip to noindex after 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)

#SegmentsTryMatch
13Propertyslug==c AND location.locality.slug==b AND location.region.slug==a (composite, D6)
22Localitykind:locality, slug==b, parent.slug==a
31Regionkind:region, slug==a
41CollectionPageslug==a (after region)
5anyRedirectfrom == '/'+segments.join('/')308
6404notFound()

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 wherepublishedPropertyWhere() (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 (cached indexable filter) / services / static, all force-dynamic + try/catch. /find-property + /compare excluded.
  • robots.txt: Disallow /admin, /api, /compare, /find-property?*.
  • Canonical/noindex: entity pages self-canonical; /find-property noindex,follow on any filter param; high-value combos become real indexable CollectionPages.
  • JSON-LD: layout = RealEstateAgent (GCR, Vaseem Khan, founded 1995) + WebSite SearchAction; Property = RealEstateListing (price/floorSize from unit.areaSqft, rooms from unit.bedrooms, geo from location.geo); Locality/Region = Place; CollectionPage = CollectionPage + ItemList; Service = Service; FAQ pages = FAQPage; non-home = BreadcrumbList.
  • CWV: next/image remotePatterns for s3.csoul.cloud; fixed aspect-ratios (no CLS); next/font Poppins/Inter (kills render-blocking Google Fonts link); ~5 client islands.
MockupRoute
home.html/
search.html/find-property
property.html/[region]/[locality]/[slug]
locality.html/[region]/[locality] (+ /[region])
compare.html/compare
post-property.htmlthe 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

PhaseGoalKey itemsEst.
0 · Spike & infraDe-risk unknownsCoolify App + DB + localproxy :5434 + Garage bucket + capacity bump gate; proxy.ts 308 spike; CF HTML-uncached + cache-purge (D10); admin behind CF Access + 2FA3–5 d
1 · Payload + migrate + go liveCMS up, migrate everything, cut over with zero SEO lossAll 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 + rollback3–4 wk
2 · Front-end templatesThe public sitelayout chrome from SiteSettings; home; [...path] resolver; /find-property (D8 sort); property/locality/region/services/company/contact; JSON-LD; next/image+S3; CWV3–4 wk
3 · Collection-page builder + polishGrowth toolingQuery tab + whereFromCollectionPage + live preview; cached indexable/liveResultCount + hysteresis (§4); compare tray + /compare; facet counts; map island2–3 wk
4 · Admin, reports, leads, WhatsApp, commercialOperate & growLead Server Action + spam stack + Resend + reports; wa.me v1; retention sweep; later BullMQ notify_whatsapp + alert matcher; commercial type2–3 wk

9Open questions to confirm with the owner

  1. Sold/booked visibility & indexing: confirm they stay live (200) with a badge for SEO equity; should sold be noindex? (Recommended: stay live; noindex optional.)
  2. ₹/sq.ft sort: mockup has none (D8 dropped it). Add a psf sort as a new product decision, or leave out? (Derived pricePerSqft kept regardless.)
  3. Shared vs dedicated host: confirm co-hosting on the CreatiSoul VPS (shares kernel/Traefik/Coolify — a host incident affects all three sites).
  4. Email provider: Resend (recommended) vs the client's Google Workspace SMTP? From-address + notification recipients (Vaseem + office)?
  5. BDA verification source: start imported BDA listings bdaVerified=false (badge hidden), or map a D7 verification field? (Badge must never show unverified — liability.)
  6. Canonical host: apex or www (match D7)? /contact-us → 308 /contact acceptable?
  7. Cloudflare control: do we have CF access for the DNS flip, ACME/grey-cloud step, and the cache-purge token (D10)?
  8. D7 access: read-only mysqldump + sites/default/files/ + the full live URL list (sitemap + GA/GSC) for migration + the parity gate?
  9. 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?
  10. Lead routing & auto-reply: route enquiries vs sell-leads differently? Enable the confirmation email to the lead (consent implication)?
  11. Manager scope: confirm Manager is barred from SiteSettings/Users/Redirects and has no delete anywhere. Future agent/broker roles?
  12. Privacy / DPDP: privacy-policy content (linked from consent) + retention window (N months for closed-lost/spam)?
  13. Compare & alerts v1: is automated "new matching listing → notify lead" needed at launch, or is the deferred manual-match alert acceptable?