# Courses / LMS (`Src\Course`)

**Portals:** Manage (authoring + preview) + Main user portal (viewing) · **Routes:** `manage.courses.*`,
`main.portal.courses.*` · **Nav:** Manage: Channel → **Portal**, the *Courses* hub tab (a course is something the portal engages a member with, not a back-office setting); Main: a flat sidebar entry labelled **"Learning Hub"** since 2026-08-26 (which since 2026-08-27 also fronts the [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md) as its first tab) (previously "Academy", and before that under a Property Passive pillar). It sits LAST in the sidebar on request — learning is the reference shelf, not the day's work. A *course* is still a course: the route NAMES, the `/property/academy` URI and the `/courses` 301 are all unchanged; only the label moved

## The member's catalogue (rebuilt 2026-08-26)

`/property/academy` is now the **Learning Hub**, whose tabs (`ShowTabs`, synced to `?tab=`) are, in
order: **[How to Use PropertyLab](/docs/modules_handbook/main/portal-guide/readMe.md)** ·
**DMAIC 之路** · **Area Guide** · **e-Learning** (this module's catalogue) · **Glossary**.

> **The order changed on 2026-09-03** (founder): the PORTAL comes before the METHOD. A member who
> has just been given an account cannot walk the road until they can work the screens the road sends
> them to. `ShowTabs` falls back to the first tab, so a bare `/property/academy` now opens
> **How to Use PropertyLab** rather than the road.

> **Nav:** **DMAIC 之路** (`?tab=road`, the FIRST tab from 2026-08-27 until 2026-09-03) is the guided road through one
> purchase — **起 · Mindset → D · M · A · I · C → T · Takeoff → 终点** — whose stage pages
> (`/property/academy/road/{stage}`, `{stage}` ∈ `mindset|define|measure|analyze|improve|control|takeoff`)
> and terminal screen are DETAIL pages of this tab, and whose "go deeper" shelves list THIS module's real
> lessons. It is its own module: [DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md).
>
> **What the road takes from this module**, all of it read-only and all of it from
> [`config/road.php`](/config/road.php) — the road owns no video and writes no lesson:
> - **A stage → chapter map.** Each stop shelves the lessons of the chapters named for it, in
>   chapter → lesson order. 起 · Mindset (added 2026-08-28) shelves course 4's own way in — the **开始**
>   chapter, then **第1单元：房地产投资基本概念** — so its shelf reads as the course does; D–C shelve
>   第2–第6单元; the Bonus **没有了90% Quota** chapter joins Define only on cycle ≥ 2 or ≥ 2 residential
>   loans. **T · Takeoff shelves a DIFFERENT course** — a stage may carry its own `course_uuid`, and T's
>   is the **Propertylab AI Class**; an EMPTY `chapter_uuids` there means *every published chapter*.
> - **The gate is this module's own.** `Course::isAccessibleTo()` on the same inputs `CoursesController`
>   uses; a locked course still renders its shelf, with the lock reason and a link to the course page
>   rather than a blank list. Lesson links are the ordinary
>   `/property/academy/{course}/lessons/{lesson}`, and ✓ comes from `LessonProgress` for the lead.
> - **`lms:recut-dmaic`** moves course 4's lessons between its chapters (by uuid — never delete/recreate,
>   so `lesson_progress` survives) so the chapters are cut by STAGE rather than by topic. It does not
>   touch 起's shelf. Runbook in the road's own doc.
> - ⚠️ A stale **`config:cache`** is what empties a shelf, not a missing lesson: `config/road.php` is
>   where the chapter uuids live.

> **The hub hosts more than this module.** **Area Guide** was a sidebar entry with a page of its
> own until 2026-08-27, when it moved in as the **first** tab, left of e-Learning: "where should I
> buy" is reference material a member READS before a course about it, not a tool they operate.
> `/area-guide` now redirects to `?tab=area-guide`. Two consequences for anyone touching this page:
> `CoursesController@index` also supplies the guide's `areaGuide` prop (its Mapbox token + the
> guide's TEMPORARY admin-only lock, which used to be path-matching middleware and could not stay
> one without locking the whole hub), and the guide's own docs live in
> [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md). `ShowTabs` lazy-mounts only the
> active tab, so three.js and Mapbox still download only for a reader who opens that tab.

### Why the catalogue was rebuilt
It was a flat grid of equal tiles, and three things were wrong with it (Udemy / Thinkific were the
stated reference):

1. **The lock state destroyed the card.** "Requires Elite Member" was white text centred over the
   artwork, so on a busy cover it collided with the course's own title and read as a rendering
   fault. A lock is a **badge**; the cover is dimmed, never covered, because the artwork is what
   sells the class.
2. **A card said almost nothing** — title and a lock line. A catalogue is scanned, so each tile now
   answers "what is this and where am I in it": lesson count, run time, progress, and one clear way
   in.
3. **Everything had equal weight.** The one course in progress sat between six locked ones.
   Resuming is the likeliest action on the page, so it has its own **Continue learning** shelf at
   the top, then *Your courses*, then *Unlock more*.

### Facts the card gained, and where they come from
- **`duration_label`** — there is **no course-level duration column**; a course's length is the SUM
  of its lessons' `duration_seconds`, which the existing eager load already has in memory (the
  select was widened to include it). Rounds to whole minutes, and returns **null** when no lesson
  carries a duration, so the card omits the fact rather than claiming "0m".
- **`lessons_done`** — "3 of 12 lessons" tells a learner where they are; a bare percent does not.
- **`in_progress`** — started but not finished. That flag IS the Continue-learning shelf.

> 🐞 **`ShowTabs` only reads `?tab=` when the model is EMPTY.** `resolveInitial()` returns
> `props.modelValue` first, so seeding the ref with a tab key silently defeats deep links — a link
> to `?tab=glossary` opened e-Learning. Pass `ref('')` and let the component fall back to the
> first tab itself. (The same bug was fixed in Landlord Management — then Renovation & Management — at the same time.)

### The Knowledge Portal tab was removed (2026-09-03)
It was a promise with a **Notify me** button: there was no article/guide/resource store in this
codebase to back it, so the tab said what it *would* hold and collected waitlist signups. Two tabs
that arrived after it answer the same need with real material — **How to Use PropertyLab** (the
portal taught on the real screen) and **Glossary** (the vocabulary as a course) — so a tab whose
only content was "coming" cost every reader a click to find that out.

Removed: the tab entry, its body, and this page's `FeatureWaitlistModal`. **Kept:**
`FeatureWaitlistSignup::FEATURE_KNOWLEDGE_PORTAL` and the rows already in
`feature_waitlist_signups` — deleting the constant would orphan real signups. Nothing collects new
ones. Building a knowledge base for real still needs what it always needed: a table (mirror
`lms_courses`' five-state `visibility` so the lock/buy/public vocabulary carries over), a cover via
a named `Media` collection, its own category const map, a read-tracking primitive, and Manage CRUD.

## What it does
The first-party learning module: admins build **Courses → Chapters → Lessons** in the Manage portal, and
members watch them in the user portal. A lesson has a title, a **rich-text body** (TipTap HTML) and a
**Vimeo** video. Each course has a **visibility**:

| Mode | Value | Who gets in |
|---|---|---|
| **Free** | 1 | Any signed-in portal learner |
| **Members only** | 2 | Holders of the tiers in `lms_course_membership` — naming **no** tier means *any* active member |
| **Internal** | 3 | Staff only: hidden from the member portal entirely; admins watch it via **Preview** |
| **Members or buy** | 4 | Those tiers, **or** anyone who buys it |
| **Buy only** | 5 | Only buyers. **No tier unlocks it** |

Modes **4 and 5** (`Course::SELLABLE_VISIBILITIES`) carry a `price` + `currency` and make the course a
`Purchasable` — see [Purchase Histories](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md).
Modes **2 and 4** (`MEMBERSHIP_GATED_VISIBILITIES`) keep the membership pivot. One column rather than a
separate `access_mode` because the five states are genuinely exclusive (there is no coherent
"Internal + priced") and values 4/5 fit the existing `unsignedInteger` with **zero data migration**.

Ineligible learners see locked cards whose copy follows what actually unlocks the course — a buy-only
course is never told to "upgrade your membership". Internal courses never appear in the member portal at
all. Members get **progress tracking** (mark complete + continue where they left off).

This replaced the read-only external "InvestHink" `/learn` module (removed); the live petav2 course content
is imported into it by the `LmsImportSeeder`.

## How it works
- **Models** (`Src\Course`): `Course` (status Draft/Published, **`visibility`** Public/Members/Internal,
  `belongsToMany Membership` via `course_membership`, polymorphic cover image via `media()`), `Chapter`
  (`belongsTo Course`, `is_published`, `sort_order`), `Lesson` (`belongsTo Chapter`, `content` HTML,
  `vimeo_url`, `duration_seconds`, `is_published`, `sort_order` + `duration_label` accessor),
  `LessonProgress` (per `lead_id` + `lesson_id`: `last_viewed_at` for resume, `completed_at`).
- **Gating** is computed server-side by `Course::isAccessibleTo(array $activeMembershipIds, array $ownedCourseIds)`
  — the **only** thing standing between a learner and a course: there is no policy, no gate and no query
  scope anywhere in the LMS. The portal reads memberships from `User::activeMemberships()` and owned courses
  from `CourseAccess::ownedCourseIds($lead->id)`, fetched **once** per request (the gate runs in a loop over
  the whole catalogue, so a per-course lookup would be an N+1).

  > ⚠️ **It fails CLOSED, deliberately.** It used to end on `empty($required) || array_intersect(...)`, which
  > returns **true for anyone holding any active membership** whenever the membership pivot is empty — and the
  > admin form empties that pivot for every non-Members mode. Adding a paid mode without the explicit
  > `VISIBILITY_BUY → false` branch would have made **every paid course free to every member**, silently, with
  > nothing in the log. The per-mode branches and the closing `return false` are the fix, not defensive style.
  > `tests/Feature/Lms/CourseAccessGateTest.php` exists so that can never ship green again.

- **Owning a course.** A purchase writes `lms_course_access` (unique `lead_id` + `course_id`) — the analogue
  of `member_subscriptions`. Access is **not** derived from `purchase_histories`: that table is soft-deletable
  with four statuses including UNCONFIRMED and EXPIRED, so one missing `status = ACTIVE` filter would open a
  course to somebody who only *started* a checkout, and it leaves no way to comp a course. Grants are
  **perpetual** (`expires_at` exists and `scopeUsable()` honours it, but is always NULL today); a refund sets
  `status = REVOKED` and **keeps the row** — "bought then given back" is a different fact from "never bought".
  `PurchaseFulfiller::fulfillCourse()` grants, `revoke()` withdraws — both **per purchase**: `lms_course_access.purchase_history_id`
  records which payment bought the access (null = comped by hand), fulfilment skips a payment that already
  granted it, and `CourseAccessRepository::revoke($lead, $course, ?$purchase)` refuses to withdraw access a
  **different** payment granted. Re-buying after a refund moves the receipt to the new payment. Registered in `IdentityChildMap` +
  `LeadRepository::dedupeThenRepoint`, so a lead merge never loses a bought course.

- **Buying.** Portal: a Buy button on the catalogue card and the course page posts to
  `POST /courses/{uuid}/checkout` (`CheckoutController::course`), which opens a **fresh** checkout carrying our
  `payment_reference` and bound to the signed-in lead before any money moves — never a redirect to an adopted
  gateway link, which could do neither. It refuses when the learner can already open the course, by purchase
  *or* by tier. Admin: a course is a payable like any other (payment links, offline entries, adoption) and has
  its own **Courses** stream on the Sales Dashboard. In "Members or buy" a member sees
  *"Normally RM X — included with your membership"* and **never** a Buy button.
- **Admin (authoring).** `CoursesController` is the §14 list + a Show "builder": the course identity card
  (cover upload, status, **visibility selector** — memberships only shown for "Members") plus a curriculum of
  chapters → lessons managed inline via modals, reordered with up/down (`sort_order`). All writes go through
  `Course/Chapter/Lesson` repositories in `DB::transaction`. Cover images use the shared **MediaService**
  (collection `cover`). Membership pivot rows are persisted for **both** membership-bearing modes, and the
  price only for the sellable ones — `mapInput()` compared against `VISIBILITY_MEMBERS` alone, which would
  have wiped the tier list of every "members or buy" course on its first save. `CourseRepository`'s two
  `data_only()` whitelists must list every new column or the form validates, flashes success and saves nothing.
- **Admin Preview / Watch.** `CoursesController@preview` renders `Manage/Lms/Preview.vue` — a player
  (Vimeo + rendered content + outline sidebar, client-side lesson switching) over the **full** course
  (drafts included). This is how staff watch an **Internal** course (which the member portal hides).
- **Portal (viewing).** `Main\Portal\CoursesController` renders the catalog (locked/public cards + progress),
  a course outline (Continue target), and the lesson player (Vimeo iframe + rendered `content` + outline +
  prev/next + Mark complete). **Every portal query excludes Internal courses** (`where('visibility', '!=',
  INTERNAL)`); locked courses redirect their lesson page to the outline with an upgrade flash. Progress
  writes go through `LessonProgressRepository`.

### Video watch tracking (the beacon)
The lesson player reports **actual watching**, not just page opens (which remain the `ActivityLogger`
trail entry). Modelled on the propertylabglobal `ih_user_events` beacon architecture, petav3-native:
keyed on **`lead_id`** (logged-in members only — no anonymous stitching step) and polymorphic
(`watchable` — lessons today, any playable model later), so it lives in **`Src\Common`**, not here.

- **Two event kinds, one duration payload.** `EVENT_MILESTONE` fires once per viewing session at
  **25/50/75/95%** (from Vimeo `timeupdate`, deduped client-side) and **100%** (from `ended`);
  `EVENT_HEARTBEAT` flushes every ~15s of playback and on pause / tab-hide / unmount. **Every event
  carries `watched_seconds`** — real playback since the previous event, accumulated from consecutive
  timeupdate deltas (a seek's jump counts nothing; rewatching counts again) — so total watch time is
  `SUM(watched_seconds)`. The server clamps the delta at `WATCHED_SECONDS_MAX` (45s) against stale or
  forged payloads.
- **Fire-and-forget end to end.** The composable swallows every error and uses `keepalive`; the
  endpoint answers **202** whether or not the write landed (an unresolvable subject silently no-ops);
  the repository **fails soft** like `ActivityLogger` — tracking never breaks the player.
- **Whitelists as a Form Request** (§8): `TrackVideoRequest` maps client keys (`lesson` / `milestone` /
  `vimeo`) through `WATCHABLES` / `EVENTS` / `PLAYERS` and resolves **published lessons only**. A new
  playable surface joins the pipeline by adding one `WATCHABLES` line + calling the composable.
- **Read side — three surfaces, one drill-down.** The Courses **index** carries an Engagement column
  (viewers · watch time + the milestone funnel `25/50/75/95/100`, from `watchSummary()`); the course
  **Show** page puts a stats line on every chapter (rolled up on DISTINCT leads, not summed lesson
  counts) and every lesson (funnel on hover), from `watchStats()`; and each **lead's** page carries a
  Property Portal → **Courses** tab (`LeadsController::courseWatch()` — beacon merged with
  `LessonProgress`, so a lesson opened before the beacon shipped still appears as "opened only").
  **Every engagement number is a button**: click slides in the shared who-watched drawer
  ([`ViewersDrawer.vue`](/resources/js/Pages/Manage/Lms/Partials/ViewersDrawer.vue) — fetched on open
  from `GET {course}/viewers?scope=course|chapter|lesson`, scope-whitelisted and bound to that course),
  listing viewers heaviest-first with watch-share bars; clicking a viewer opens their lead page in a
  **new window** on purpose, so the drawer and the analysis survive the visit.

## Reference usage
- **Rich text** — the reusable [`resources/js/Components/RichTextEditor.vue`](/resources/js/Components/RichTextEditor.vue)
  (TipTap; `v-model` = HTML). Used by the lesson form and rendered (via `v-html`) by the lesson player +
  admin preview; reuse it for any HTML body field.
- **Cover image** — [MediaService](/docs/modules_handbook/shared/media/readMe.md) (`storeUpload(..., ['collection' => 'cover'])`).

## Related files
**Backend — Models / Repositories**
- [src/Lms/Course.php](/src/Lms/Course.php) (STATUSES, **VISIBILITIES**, `isAccessibleTo`, `isInternal`, `memberships`, `media`) · [Chapter.php](/src/Lms/Chapter.php) · [Lesson.php](/src/Lms/Lesson.php) · [LessonProgress.php](/src/Lms/LessonProgress.php)
- [src/Lms/Repositories/](/src/Lms/Repositories/) — `Course`, `Chapter`, `Lesson`, `LessonProgress` repositories.

**Video watch beacon**
- [src/Common/VideoWatchEvent.php](/src/Common/VideoWatchEvent.php) (append-only; `EVENTS`, `PLAYERS`, `MILESTONES`, clamp consts) + [src/Common/Repositories/VideoWatchEventRepository.php](/src/Common/Repositories/VideoWatchEventRepository.php) (fail-soft `record()`).
- [app/Http/Controllers/Main/Portal/TrackVideoController.php](/app/Http/Controllers/Main/Portal/TrackVideoController.php) (`POST /track/video`, 202-always) + [app/Http/Requests/Main/Portal/TrackVideoRequest.php](/app/Http/Requests/Main/Portal/TrackVideoRequest.php) (whitelist maps + published-only resolution).
- [resources/js/composables/useVideoWatchTracking.js](/resources/js/composables/useVideoWatchTracking.js) (`@vimeo/player` wrapper: milestones + duration accumulation) — wired in [Lesson.vue](/resources/js/Pages/Main/Portal/Lms/Lesson.vue).
- Migration `2026_07_28_100001_create_video_watch_events_table` · read side in [Manage CoursesController::watchStats()](/app/Http/Controllers/Manage/Lms/CoursesController.php) + the Show page's per-lesson stats line.

**Backend — Controllers**
- [app/Http/Controllers/Manage/Lms/](/app/Http/Controllers/Manage/Lms/) — `Courses` (index/store/show/**preview**/update/activate/deactivate/destroy/cover), `Chapters`, `Lessons`.
- [app/Http/Controllers/Main/Portal/CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php) — the Learning Hub: catalog / outline / player / progress (Internal excluded), plus the hub's `road` and `areaGuide` props.

**Backend — Form Requests**
- [app/Http/Requests/Manage/Lms/](/app/Http/Requests/Manage/Lms/) — query, store/update (course/chapter/lesson; course validates `visibility`), cover, reorder.

**Frontend (Vue)**
- Admin: [resources/js/Pages/Manage/Lms/](/resources/js/Pages/Manage/Lms/) — `Index`, `Show` (builder), **`Preview`** (player), `Partials/*` (form modals, visibility selector).
- Portal: [resources/js/Pages/Main/Portal/Lms/](/resources/js/Pages/Main/Portal/Lms/) — `Index` (catalog), `Show` (outline), `Lesson` (player).
- Shared: [resources/js/Components/RichTextEditor.vue](/resources/js/Components/RichTextEditor.vue).

**Migrations** (`database/migrations/2026_06_16_*`)
- `000005_drop_abandoned_lms_tables` — drops the abandoned `lms_*` port.
- `000006_create_lms_courses_table` (`status`, **`visibility`**, `sort_order`) · `000007_create_lms_course_membership_table` (pivot) · `000008_create_lms_chapters_table` · `000009_create_lms_lessons_table` · `000010_create_lms_lesson_progress_table`.

**Seeder**
- [database/seeds/LmsImportSeeder.php](/database/seeds/LmsImportSeeder.php) — imports the live petav2 LMS content from [database/data/lms_content.sql](/database/data/lms_content.sql) (stages the legacy `lms_*` tables, transforms topics→Courses / chapters→Chapters / lessons→Lessons — categories flattened away, "Staff/Internal" titles → Internal visibility, gating defaults to Members — then drops the staging tables). Registered in `DatabaseSeeder`; re-runnable.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.courses.*` (incl. `manage.courses.preview`) · [routes/main.php](/routes/main.php) — `main.portal.courses.*` (under `['auth','main']`).

## Known gaps
- 🔴 **`lms_lessons.vimeo_url` is an unsigned URL rendered straight into an `<iframe :src>`.** One legitimate
  buyer opening one lesson yields an address that is copy-pasteable and replayable forever — no signing, no
  expiry, no re-check. Membership gating tolerated this; **charging money does not.** Configure Vimeo-side
  privacy + domain restriction **before the first paid course goes live**. This is a Vimeo setting, not code.
- **`CoursesController@preview` carries no `manage-courses` permission** — the only route in the courses group
  without one — and emits `content` + `vimeo_url` for every lesson including drafts, so a view-only admin can
  watch everything. Staff-scoped; left as-is by decision on 2026-07-30, worth revisiting as paid courses grow.
- `lms_course_membership` has no timestamps and neither `belongsToMany` declares `withPivot()`, so per-tier
  pricing is not expressible without schema work.

## Notes
- **The road pins chapter uuids.** [`config/road.php`](/config/road.php) names live chapters of course 4
  (and of the AI class) by uuid. Deleting or unpublishing one of them does not break the page — the shelf
  simply loses those lessons, silently — so a chapter rename is safe and a chapter *deletion* is not.
  See [DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md).
- Imported petav2 lessons have **no description / rich content / duration / cover** in the source (Vimeo URL
  only) — fill those in the admin builder over time.
- Visibility uses constants `Course::VISIBILITY_PUBLIC|MEMBERS|INTERNAL|MEMBERS_OR_BUY|BUY`; the old
  `is_public` boolean was replaced before any release. The two paid modes were added 2026-07-30 —
  migrations `2026_07_30_000001_add_pricing_to_lms_courses` and `..._000002_create_lms_course_access_table`.
  The seven pre-existing courses were untouched: both new columns are nullable and no visibility changed.
