# Activity Log (Shared · `Src\Common`)

**Service:** `Src\Common\Services\ActivityLogger` · **Table:** `activity_logs` · **Surfaced on:** Manage → Leads → Show → *Activity* (an outer tab, not nested under Property Portal — the trail now carries admin-made rows too, e.g. lead distribution, admin-added registrations)

> ⚠️ **Not `activities`.** That table belongs to [`Src\Calendar\Activity`](/src/Calendar/Activity.php) and is a
> **scheduled calendar entry** (a meeting, with `start_at` / `end_at` / `join_url`). This is a **past-tense
> trail** of things that already happened — hence `activity_logs`.

## What it does

Records what happened to a lead — not only what the lead did to themselves — so staff can answer "is
this person actually engaged?" from the lead's own page instead of asking them. Most rows trace
**portal customers** (`resources/js/Pages/Main/Portal/**`); the trail already carries **admin-made**
rows too (lead distribution, an admin adding a session registration on the lead's behalf) — the actor
is simply a different user, named on the row when it is not the lead themselves.

## How it works

- **One call, no ceremony.** A feature says what happened and hands over the row it happened to; the
  actor (signed-in user) and the lead (the actor's own) are resolved inside the service.
- **It never breaks its caller.** A trail is a bystander: the customer's action has already happened, so
  a failure to record it is caught and logged, never thrown. `log()` returns `null` instead.
- **It coalesces repeated edits.** `TYPE_*_UPDATED` types fold into the previous row within
  [`ActivityLog::COALESCE_MINUTES`](/src/Common/ActivityLog.php) (30). **This is load-bearing**: the wealth
  canvas autosaves on *every keystroke*, so one row per request would bury every real action under
  thousands of "edited" lines and grow the table without bound.
- **Titles are frozen at write time**, so the trail still reads correctly after the subject is renamed or
  deleted — and delete actions are logged *before* the delete, while the subject still exists.
- **No lead, no row.** A row with no lead has nowhere to be read from, so it is skipped rather than
  accumulated.
- **⚠️ That is NOT what keeps STAFF out of the trail — nothing does, by default.** This section used to
  say "an admin previewing the portal has no lead", and it stopped being true when
  [`EnsureUserIsMainUser`](/app/Http/Middleware/EnsureUserIsMainUser.php) began creating one for every
  portal visitor — *"members, non-members and admins alike"*, by its own comment. An admin therefore
  **does** have a lead, and `log()` will file against it happily: their preview lands on their own lead
  page and counts toward the Portal → Activity tiles that sales reads to judge customer engagement.
  **A caller that staff can reach must ask `$user->isManageUser()` itself.** The worked example is
  [`Main\Portal\AiLearningController::trail()`](/app/Http/Controllers/Main/Portal/AiLearningController.php),
  where it matters most because admins author from the portal by design — but the same hole is open in
  every other portal area, and cleaning those up is its own ticket.

### Reference usage — the portal's property analysis (reference implementation)

The canonical consumer is
[`AnalyzePropertyController@store`](/app/Http/Controllers/Main/Portal/AnalyzePropertyController.php) —
the headline portal action. Copy this shape:

```php
app(ActivityLogger::class)->log(
    ActivityLog::TYPE_ANALYSIS_CREATED,
    $analysis,                                   // the subject row
    "Ran a property analysis — {$what}",         // frozen label; WHICH property is the useful part
    ['verdict' => $analysis->verdict],           // small context, staff-readable
);
```

Full signature — everything after the type is optional:

```php
log(int $type, ?Model $subject = null, ?string $title = null, array $meta = [], Lead|int|null $lead = null, ?User $actor = null, ?Carbon $occurredAt = null): ?ActivityLog
```

Pass `$lead` / `$actor` only when they are NOT the signed-in customer — that is the seam the
registration paths and admin tracing use. Two cases where getting it wrong fails *silently*:

- **An unauthenticated caller** (the landing registration form) has no signed-in user, so the
  default lead resolves to `null` and the row is dropped by the no-lead skip. Pass `$lead`.
- **A guest landing registration** should pass the **actor** as well, as the lead's own account
  (`$lead->user`). Both landing paths do — the single posted slot and the funnel-wide enrol —
  because the same person doing the same thing must not be attributed to themselves or to "the
  system" depending only on which landing page they arrived through. Nothing on the timeline
  shows the difference; a filter or report by actor would. A null actor here means genuinely no
  account (a phone-only lead), which is a different fact.
- **An admin acting on somebody else** must pass `$lead` too — the default resolves the lead from
  the *actor's own* `->lead`, and staff are full leads here, so the row would land on the admin's
  own trail. That trail exists, so nothing looks broken.

`$occurredAt` defaults to `now()` and is only for a **backfill** of something historic (the Zoom
registrant import adopts sign-ups made weeks ago). Coalescing keeps using `now()` for its window
check, so a backfilled row can never fold into a live one.

**Adding a new action:** add a `TYPE_*` constant **with a fresh integer** (never reuse one — the number
is stored) plus its `TYPES` entry (name + colour) **and its `TYPE_CATEGORIES` entry**, and add it to
`COALESCED_TYPES` if it fires repeatedly.

### Categories

`TYPE_CATEGORIES` groups every type into one of nine `CATEGORY_*` areas (Property, Learning,
Sessions, Wealth, AI, Concierge, Payments, Account, Area Guide), with `CATEGORIES` holding each
one's label and colour. It is a **derived grouping, never a column** — re-grouping a type or
renaming a category is a code change with no migration and no backfill.

`ActivityLog::typesIn($category)` returns that category's types, so a filter compiles to one
`whereIn('type', …)`. It returns `[]` for an unknown category — callers must treat that as "no
filter", never as `whereIn([])`, which matches nothing and reads as "this lead did nothing".

`ActivityLoggerTest` asserts every `TYPES` key has a `TYPE_CATEGORIES` entry, so a type added
without one fails a test instead of silently falling into "Account".

### What IS logged (every portal area)

| Area | Logged |
| --- | --- |
| Wealth planning | plan created / edited (coalesced) / deleted · WhoPay report saved / deleted · **owned property** added / edited (coalesced) / removed on the public Financial Report link (`FinancialReportController`; lead passed explicitly, actor = whoever is signed in on that browser, usually nobody) |
| Property analysis | analysis run (names the property) / deleted · financing adjusted (coalesced) · photo or layout plan uploaded |
| Concierge | request submitted (names the property) |
| AI | conversation started / deleted · **settings changed** (the grounding toggles decide whether the member's own wealth + analysis data is sent to a third-party AI provider — a consent decision, not cosmetics) |
| Learning | course opened (coalesced) · lesson opened (coalesced) · lesson completed |
| AI E-Learning | resource **downloaded** / micro-site **opened** (both coalesced per resource) · community **topic** or **reply** posted (never coalesced — every question is its own act). Staff-guarded, and the only area whose aggregate-dashboard source is `activity_logs` itself rather than a feature table — see its module handbook |
| Funnels / sessions | **funnel registration** (names the funnel) · **session registration** (names the session + its schedule). Four paths write the session row and each is hooked at its own CALL SITE, never in the shared repository: the slot landing (`RegisterLeadAction`), the funnel-wide auto-enrol (`EnrollLeadInFunnelSessionsAction` — one row per upcoming public session, so a funnel with 8 sessions writes 8), the admin add (`Manage\Events\RegistrationsController@store`, admin as actor), and the Zoom-form import (`ImportWebinarRegistrants`, **dated from Zoom's `create_time`, converted into the app timezone**, no actor). Every one gates on `wasRecentlyCreated`, since `join()` / `attach()` are `firstOrCreate` and a returning registrant must not add a second line |
| Property interest | **project detail page viewed** (coalesced per project) — names the development and carries its area / state / developer / segments in `meta`, so sales can route the follow-up by location (a lead reading Penang launches goes to the Penang closer). Fires from `Main\Site\ProjectDetailController@show`, which is also where the portal's Analyze Property → New Project tab lands, so both routes in are covered by one line |
| **Area Guide** | area **opened** · area **story read** · **360 view** opened · **building** opened · area **video** played (types `110`–`114`, all coalesced). The only area whose acts reach the server through a **beacon endpoint** — see below |

**Why the Area Guide is different, and the one thing to copy from it.** Every other row above is a
side effect of a request the server already handles. Nothing in the Area Guide is: a chapter
scrolled past, a 360 view opened over the map, a building drawer, a video played — the whole guide
is one page, and none of it is a navigation. So it has a reporter
(`resources/js/composables/useAreaGuideActivity.js`) posting to
`POST /property/academy/area-guide/activity` (`Main\Portal\AreaGuideController@activity`). Three
rules make that safe, and any future beacon should copy all three:

1. **The act is a NAME, never a type number.** `App\Http\Requests\Main\AreaGuide\ActivityRequest::ACTS`
   maps five strings to five `TYPE_*`. This is the first endpoint a *reader* can write trail rows
   with, and a free-numbered type would let one post "Paid for a membership" onto their own lead.
2. **It can never break the page, in either direction.** The reporter swallows every failure and
   nothing waits on it; the endpoint answers `{"recorded": false}` — not a 422 — for an area that is
   not in the guide, because the reader is mid-scroll and cannot act on an error anyway.
3. **Acts, never time** (user's decision, 2026-09-19). There is no heartbeat and nothing accumulates
   seconds, so *"how long did they look at it"* is a question this trail cannot answer — say so
   rather than implying otherwise. Adding duration later needs its own volume review; it is not a
   parameter on this.

The `story` row carries `{area, chapter, chapters}` and, because coalescing folds later reports into
the **first** row, the number kept is how **far** the reader got — "reached chapter 3 of 6" — not
whichever chapter happened to fire last.

### What is deliberately NOT logged

| Not logged | Why |
| --- | --- |
| Page views (dashboards, lists, catalogs) | Volume with no signal — a list view has no subject to name. The project DETAIL page is the exception: it names one development, which is the signal sales acts on |
| Guest (signed-out) project views | The trail hangs off a lead; an anonymous visitor has none, so there is nothing to follow up and nothing to attach it to. Stitching a pre-login session onto the lead it later becomes is a separate piece of work |
| `touchOpened` (opening the plan editor) | A GET side effect, not something the person *did* |
| Individual autosaves / lesson re-opens | Folded into one row — see coalescing above |
| **AI chat messages** | Already recorded, in far richer detail (prompt key, tokens, cost, subject, provider), in `ai_requests` ([AI handbook](/docs/modules_handbook/shared/ai/readMe.md)). A trail line would duplicate it at per-message volume |
| Un-completing a lesson | The completion is the signal; un-ticking is not news |
| **Session no-shows** | `closeAttendance()` bulk-flips every still-registered row in one `UPDATE`. A no-show is the *absence* of an action — logging it would write a row per non-attendee for something nobody did |
| **Session attendance** | Not yet. `73` is reserved for `TYPE_SESSION_ATTENDED`, but attendance already has two dedicated tabs (Zoom, Physical Events), and the reconcile job that would write it is the highest-volume writer in the system — it needs its own volume review first |
| The un-funneled `attachDefaultSource()` | The same repository write with a **null funnel** (the "no attribution" backfill every ingestion path calls). Logging it would put a nameless "Registered for " on every imported lead's trail — which is why the funnel hook lives in the action and gates on `$funnelId` |

### Known blind spots — things the system genuinely cannot see

- **Video watching.** Lessons embed a **Vimeo iframe that reports nothing back** — no play event, no
  interval ping. So "opened the lesson" is the closest signal that exists; "watched 8 of 12 minutes" would
  need a Vimeo Player SDK integration plus a beacon endpoint. Do not imply the trail knows more than it does.
  The **beacon half of that now exists as a worked example** — the Area Guide's activity endpoint above —
  so a lesson-video integration is no longer a from-scratch build. What it still would not give you is
  *duration*: the Area Guide records that a video was **played**, never for how long, by the same
  acts-not-time decision.
- **How long anyone spent anywhere.** Nothing in this system measures dwell time — not a lesson, not a
  project page, not an area guide. Every row answers *what they did*, and a question about minutes has
  no answer here, only a plausible-looking guess.
- **The Membership WhatsApp CTA** is a plain `<a href="wa.me/…" target="_blank">` — it never reaches the
  server, so it cannot be logged without inventing a redirect/beacon route (the pattern exists: see the
  WhatsApp CTA short link `/wa/{slug}`).

### A trap worth knowing

Hook the **controller**, not the repository, when a writer is shared. `AiConversationRepository::update` is
called both by the user's settings change **and** by `autoTitle()` on a thread's first message — a
repository-level hook would report a phantom "settings changed" every time someone starts chatting.

The registration writers are the same trap at larger scale: `EventRegistrationRepository::join()` is
called by four callers that mean four different things (landing capture, funnel-wide enrol, admin add,
Zoom-form import — differing in actor, in wording and in *when it happened*), and
`LeadFunnelRepository::attach()` is called both by a real funnel registration and by the null-funnel
`attachDefaultSource()` backfill. One repository hook would flatten all of that into a single
indistinguishable row.

## Data model (`activity_logs`)

| Column | Notes |
| --- | --- |
| `lead_id` | The lead the action concerns. **Nullable** so a future non-lead admin action still fits |
| `user_id` | The actor — customer today, admin later; null = system |
| `type` | `unsignedInteger`; see `ActivityLog::TYPES` |
| `subject_type` / `subject_id` | Nullable morph to the row acted on |
| `title` | The human summary, frozen at write time |
| `meta` | Small json context. **Keep PII out** — staff read this |
| `occurred_at` | Indexed with `lead_id` — exactly the Show tab's query |

No `uuid` (never addressed by URL), no soft delete (a trail you can hide is not a trail), no blame columns
(`user_id` *is* the actor). A log is immutable except for coalescing.

## Related files

**Backend**
- [src/Common/ActivityLog.php](/src/Common/ActivityLog.php) — the model: `TYPE_*` constants, `TYPES` metadata, `COALESCED_TYPES`, `scopeForLead`.
- [src/Common/Services/ActivityLogger.php](/src/Common/Services/ActivityLogger.php) — **the API every feature calls**.
- [src/Common/Repositories/ActivityLogRepository.php](/src/Common/Repositories/ActivityLogRepository.php) — the writes (GUIDELINES §2).
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `buildActivityRows()` (capped at `ACTIVITY_ROWS`, first paint) + `buildActivitySummary()` (totals / momentum / per-category rollups over the WHOLE trail — the `activitySummary` prop on both `show()` and `quick()`).
- [app/Http/Controllers/Concerns/BuildsActivityRows.php](/app/Http/Controllers/Concerns/BuildsActivityRows.php) — the ONE row mapper (`activityRow()`) shared by `buildActivityRows()`'s capped slice and `LeadActivityController`'s filtered pages, so first paint and every filtered page render identically (GUIDELINES §14's "one mapper, every consumer" rule).
- [app/Http/Controllers/Manage/Leads/LeadActivityController.php](/app/Http/Controllers/Manage/Leads/LeadActivityController.php) — `GET {id}/activity`, the filtered/paginated trail every tab interaction beyond first paint hits.
- [app/Http/Requests/Manage/Leads/LeadActivityQueryRequest.php](/app/Http/Requests/Manage/Leads/LeadActivityQueryRequest.php) — `category` / `type[]` / `q` / `date_from` / `date_to` filters. A non-string `category` or `q` (an array arriving as `?category[]=x`) and a non-numeric `type` are both no-ops, never a 500 or a false "no activity".
- Consumers: [WealthPlanningController](/app/Http/Controllers/Main/Portal/WealthPlanningController.php) · [AnalyzePropertyController](/app/Http/Controllers/Main/Portal/AnalyzePropertyController.php) · [ConciergeController](/app/Http/Controllers/Main/Portal/ConciergeController.php).
- Beacon consumer (the only one a READER writes through): [AreaGuideController@activity](/app/Http/Controllers/Main/Portal/AreaGuideController.php) + [ActivityRequest](/app/Http/Requests/Main/AreaGuide/ActivityRequest.php) — `ACTS` is the named whitelist that keeps a reader off arbitrary `TYPE_*`.
- Registration consumers: [RegisterLeadAction](/app/Actions/RegisterLeadAction.php) (funnel + slot landing) · [EnrollLeadInFunnelSessionsAction](/app/Actions/EnrollLeadInFunnelSessionsAction.php) (funnel-wide enrol) · [RegistrationsController](/app/Http/Controllers/Manage/Events/RegistrationsController.php) (admin add) · [ImportWebinarRegistrants](/app/Jobs/Zoom/ImportWebinarRegistrants.php) (Zoom-form backfill).
- [src/Event/Event.php](/src/Event/Event.php) — `registration_trail_title`, the one wording all four session paths write (they land next to each other on one timeline, where differing wording reads as different actions).
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) — `activity_logs` is re-pointed by the account **merge** (the trail follows the person) and deleted by the lead **purge** (no orphans).

**Frontend**
- [resources/js/Pages/Manage/Leads/Partials/Tabs/ActivityTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/ActivityTab.vue) — summary cards, the category-strip filter, search / date / type filters, the timeline (category tag + whitelisted `meta` chips + actor), and "Load more". Read-only mode (`leadReadonly` inject, e.g. `LeadDetailModal`) shows the summary cards only — no filter bar, no second fetch path. Filter state is local `ref`s + a `watch` → debounced fetch, deliberately NOT `useResourceIndex` (which would fight `ShowTabs`' own `?tab=` / `?ptab=` on this URL).
- [resources/js/utils/activityColors.js](/resources/js/utils/activityColors.js) — the ONE colour-tone map (dot + chip classes) for every colour `ActivityLog::TYPES` / `::CATEGORIES` can emit. A local map that only covered the colours that existed when it was written is the exact bug this replaces (`blue` / `sky` / `amber` silently rendered grey) — add a colour here BEFORE using it in a new `TYPES` / `CATEGORIES` entry.
- [resources/js/Pages/Manage/Leads/Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) / [resources/js/Components/LeadDetailModal.vue](/resources/js/Components/LeadDetailModal.vue) — the tab entry (badge count = the TRUE total, read off `activitySummary.total`, not a second COUNT query).
- [resources/js/composables/useAreaGuideActivity.js](/resources/js/composables/useAreaGuideActivity.js) — the reporter. Says each act once per visit, `keepalive: true` so a report fired as the reader clicks away still leaves, and every failure swallowed.

**Migration**
- [database/migrations/2026_07_17_000001_create_activity_logs_table.php](/database/migrations/2026_07_17_000001_create_activity_logs_table.php)

**Tests**
- [tests/Feature/Common/ActivityLoggerTest.php](/tests/Feature/Common/ActivityLoggerTest.php) — coalescing, the never-break-the-caller rule, no-lead skip, backfill dating, the every-type-has-a-category guard.
- [tests/Feature/Event/RegistrationActivityTest.php](/tests/Feature/Event/RegistrationActivityTest.php) — the registration rows: the unauthenticated lead resolution, idempotency on every path, the null-funnel silence, and the admin path filing against the LEAD rather than the admin.
- [tests/Feature/Main/Portal/AreaGuide/AreaGuideActivityTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideActivityTest.php) — the beacon's fence: each act writes its own type under the Area Guide category, re-reading a story deepens ONE row, an arbitrary act name is 422'd, an unknown area is accepted-and-dropped, and neither a signed-out nor a locked-out reader writes anything.
- [tests/Feature/Manage/Leads/LeadActivityFilterTest.php](/tests/Feature/Manage/Leads/LeadActivityFilterTest.php) — the filter endpoint (category / type / search / date, the per_page clamp, the LeadVisibility gate, cross-lead isolation), `buildActivitySummary()`'s totals / per-category rollups / the DISTINCT-subject `projects_viewed` count (including a NULL `subject_id`) / an unmapped type falling back to `account`, and that a non-string `category`/`q` or non-numeric `type` is a no-op rather than a 500 or a false empty result.

**Routes** — none of its own. It is a service; it rides on the actions that call it. The one route
that exists *only* to feed it, `POST /property/academy/area-guide/activity`, belongs to the Area
Guide and is declared in `routes/main.php` beside the rest of that module.

## Gotchas

- **The trail grows forever.** The Show tab's first paint loads only the newest `ACTIVITY_ROWS` (50)
  and shows the true total. Full history is `GET /manage/leads/{id}/activity`
  ([`LeadActivityController`](/app/Http/Controllers/Manage/Leads/LeadActivityController.php)) — a
  filtered, paginated JSON endpoint every category chip / search / date range / "Load more" click
  round-trips to. Do **not** lift the first-paint cap instead of using it — filtering client-side
  over 50 loaded rows would silently filter a slice and present it as the whole trail.
- **Never renumber a `TYPE_*`.** The integer is stored; reusing one silently rewrites history.
- **`occurred_at` holds LOCAL time, not UTC.** Every row written through the default path comes from
  `now()`, which is `Asia/Kuala_Lumpur` (`config('app.timezone')`). So anything passing `$occurredAt`
  from a third-party payload must convert it first — Zoom sends UTC, and handing back its Carbon
  unconverted files the row **eight hours early** in the same column, putting an evening's
  registrations on the previous afternoon with nothing to show anything is wrong. See
  `ImportWebinarRegistrants::registrantCreatedAt()`.
- **Adding a lead-owned table?** Add it to `purgeRows()` *and* `mergeVerifiedPair()` — this one is, and a
  trail stranded on a retired lead is exactly the bug the merge exists to avoid.

## Related modules

- [Leads (Manage)](/docs/modules_handbook/manage/leads/readMe.md) — the Show page that surfaces this.
- [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) — the account merge the trail must survive.
