# Property Interest (shared)

**Tables:** `lead_project_views`, `lead_floor_plan_views` ·
**Action:** `App\Actions\RecordPropertyInterest` ·
**Repository:** `Src\Lead\Repositories\LeadInterestRepository` ·
**Surfaced on:** Manage → Leads → Show (and the shared Lead modal) → *Property Portal* → *Property Interest*

## What it does

Records **which developments and layouts a lead is reading**, so sales routes
the follow-up by location and unit type instead of guessing — a lead deep in
Penang launches goes to the Penang closer, with a brief that already says
"3-bed, 1,100 sf, ~RM1.2m".

> **Not the activity trail.** [`activity_logs`](/docs/modules_handbook/shared/activity-log/readMe.md)
> is append-only history answering *"what did this lead do, when"*. These are
> rollups answering the reverse, which the trail cannot serve at any scale:
> *"which leads are browsing Penang"*, *"who is looking at this launch"*,
> *"which layout are they weighing"*. Those need indexed columns, not a JSON
> `meta` scan. Both are kept, deliberately.

## How it works

- **One row per (lead, project) and per (lead, floor plan)** — the unit sales
  reasons about is a lead's interest in a *thing*, not each individual hit.
- **Six hooks, zero frontend work.** Every signal is an endpoint that already
  reaches the server with the identity needed, in
  `Main\Site\ProjectDetailController`:

  | Hook | Records |
  | --- | --- |
  | `show` | `view_count++`, `last_viewed_at` |
  | `amenityDemand` | `amenities_viewed_at` |
  | `supply` | `supply_viewed_at` |
  | `analysis` | `analysis_viewed_at` **+ the layout** (`floor_plan` uuid) |
  | `unitRental` | `units_viewed_at` **+ the layout** |
  | `agents` | `contact_viewed_at` — highest intent, they want a human |

  The portal's Analyze Property → New Project tab lands on the same detail
  page, so both routes in are covered once.
- **Milestones are timestamps, not a type column**, ordered weakest to
  strongest in `LeadProjectView::MILESTONES`. That makes the query sales
  actually wants — *"opened Contact Advisor on a Penang project this week"* —
  one indexed join with no grouping, and `furthestMilestone()` gives the UI its
  headline without hardcoding a label.
- **A milestone keeps the FIRST time it was reached** (`COALESCE` on upsert).
  Restamping would silently move "asked for an advisor 3 weeks ago" to "today"
  and reorder the follow-up queue for no reason.
- **The counter is atomic** — `INSERT … ON DUPLICATE KEY UPDATE view_count =
  view_count + 1`, so two tabs opening at once neither lose a count nor collide
  on the unique index.
- **It is a bystander.** `RecordPropertyInterest` swallows and logs any failure:
  the page has already rendered, and tracking must never break it. Guests are
  skipped before any query runs, so public traffic pays nothing.
- **The layout is validated against the project** — the `floor_plan` uuid
  arrives from the query string and is not trusted.

## Known limits

- **Signed-in leads only.** Both tables key on `lead_id`; an anonymous visitor
  has none. Capturing pre-signup browsing needs a session-keyed table plus a
  merge at registration — deliberately out of scope, and NOT solvable by making
  `lead_id` nullable (MySQL allows many NULLs in a unique index, so rows would
  duplicate).
- **Heat-mapping is a different question.** Clarity/Hotjar/PostHog are
  aggregate and anonymous — useful for improving the page, useless for "which
  lead viewed which layout". Not a substitute for these tables.

## Reference usage

Record interest from any project surface — everything after the project is
optional:

    app(RecordPropertyInterest::class)->execute($request->user(), $catalogue, LeadProjectView::MILESTONE_UNITS, $plan);

Backfill from the trail already accumulating (re-runnable, widens rather than
double-counts; `--dry-run` reports and writes nothing):

    php artisan leads:backfill-interest

## Related files

- `src/Lead/{LeadProjectView,LeadFloorPlanView}.php`,
  `src/Lead/Repositories/LeadInterestRepository.php` (+ facade)
- `app/Actions/RecordPropertyInterest.php`,
  `app/Console/Commands/BackfillLeadInterest.php`
- `database/migrations/2026_08_03_100001_create_lead_interest_tables.php`
- `app/Http/Controllers/Main/Site/ProjectDetailController.php` (the six hooks)
- `app/Http/Controllers/Manage/Leads/LeadsController.php` → `buildPropertyInterestRows`
- `resources/js/Pages/Manage/Leads/Partials/Tabs/PropertyInterestTab.vue`
- `tests/Feature/Main/Portal/Analyze/{PropertyInterestTest,ProjectViewTrackingTest}.php`
