Learn How-to Guide Public

ERPat Learn (LMS) — Public Portal & Course Platform Implementation Plan

ERPat Learn (LMS) — Public Portal & Course Platform Implementation Plan Status: Proposal / research complete — awaiting go-ahead to implement. Scope: Fix the public course portal…

Guide version: r1 Module version: 1.0.0 Updated: 2026-07-22 Estimated time: 15 min 1 views

Status: Proposal / research complete — awaiting go-ahead to implement. Scope: Fix the public course portal (remove demo data), unify the two learner surfaces, add points, upgrade the authoring editor, and add branded certificates (templates now, JS designer later). Design principle: target simplicity — reuse the working runtime, delete mock data, defer anything without a real user today.

Visualize it: open docs/mockups/learn-public-portal.html in a browser. It's a self-contained interactive mockup (Catalog → Course detail → My Courses → Certificate → Certificate template editor) using the ERPat brand tokens, with light/dark toggle. No PHP, no build step — just open the file.


0. TL;DR

The learner runtime already works end-to-end — a user can self-enroll in a free course, take it module-by-module, sit auto-graded quizzes, and receive a certificate at 100%. The only real problem is the landing pages around it are stuffed with demo data. Two parallel catalog surfaces exist and duplicate markup:

  • Course_portal — authenticated, in-app, has browse + detail + working enroll, but is demo-laden.
  • University — public (guest), tenant-resolving, clean + gated, but browse-only (no detail, no enroll).

The plan: keep both, give each one clear job, share one card partial + one catalog model. Then layer on points, an easier authoring editor, and branded certificates — each an additive phase ordered by value-to-effort.


1. Current state

1.1 What already works (reuse as-is)

Capability Where Status
Free self-enrollment (one/user, dedup-guarded, active-only) Course_portal::enroll() (Course_portal.php:53-90) Works. Only defect: redirects to my_courses list instead of the player (:85).
Access gate Course_enrollments_model::get_approved_enrollment() (status IN enrolled|completed) Single clean predicate; used by player + attempts.
Module-by-module player, progress bar, resume, auto-complete Learn_player::course/module/complete Works. Completion is honor-system ("Mark complete").
Auto-graded quiz (single/multiple/enumeration/essay) + optimistic-lock grading Learn_attempts::exam/submit/result, _grade_question Works. Pass mark hardcoded 70.
Auto certificate issuance at 100% (idempotent, unique token) Learn_player::_issue_certificate() Works. Renders HTML + window.print(); no PDF/branding.
My Courses / My Certificates (page + profile tabs) Course_portal, Learn_certificates, config/profile_tabs.php Works.
AI whole-course generator (course→modules→group→discussion→exam→questions→choices as draft) Learn_ai + Learn_course_blueprint + Learn_assist_log Works, well-factored (validate/clamp/soft-delete rollback, audited).
Per-tenant public catalog gating + hero copy University (GuestController) + learn_university_settings Works; browse-only.
PDF engine + QR helper (for certificates) application/libraries/Pdf.php (mpdf ^8.2), get_qrcode_image() Installed, proven in Payslip_preview.php; unused by Learn.

1.2 Demo / mock / dead data to remove (exact locations)

modules/Learn/views/courses/portal/index.php

  • :124 — hardcoded <h1>UNDERSTAND YOUR ROLE</h1>
  • :131-139 — difficulty filter buttons (client-side .active toggle only, filters nothing)
  • :143-155 — category filter buttons (client-side only; JS at :258-266 just toggles class)
  • :166 — external placeholder thumbnail https://ito-group.com/.../no-image.jpg
  • :185 — dead "Display More" button (no handler, no pagination)
  • :189-196 — fake "Start for Free" jumbotron with dead <a href="#">
  • :200-238 — hardcoded testimonials (Jane Doe / John Smith / Emily Clark + i.pravatar.cc avatars), doubled to fake a carousel
  • :244-253 — footer with dead # Privacy/Terms/Contact links

modules/Learn/views/courses/portal/view.php

  • :22-28 — hardcoded "Sample labels" (Label 1-4)
  • :57-64 — fake rating ★★★★☆ Based on 128 reviews
  • :68-74 — hardcoded "Intermediate level" difficulty block
  • :84 — same no-image.jpg in Recommended Courses
  • :103-111 — dead footer links

modules/Learn/views/courses/university/index.php

  • :27 — same no-image.jpg placeholder; catalog cards are non-clickable <div>s (no detail/enroll target)

Dead schema / reserved-never-used

  • learn_certificates.score (never written), learn_certificates.pdf_path (reserved)
  • learn_university_settings.self_enroll_enabled (persisted, read nowhere — lang says "reserved for a future release"), footer_links (unused)

1.3 The key architectural decision — reconcile the two portals

Keep BOTH surfaces but give each ONE clear job, sharing ONE card partial and ONE catalog model.

  • University = public discovery (guests): catalog + course detail/preview. On "Enroll", hand off to login with a return-URL back to Course_portal/view, where enrollment auto-runs. (Industry norm: discovery has no login wall; account required only at enroll.)
  • Course_portal = authenticated take-the-course surface (in-app): browse (superset — internal + public), enroll, my courses, points.

This avoids exposing enroll() to guests (no loosening of App_Controller auth), deletes the duplicated card markup, and makes "internal by default / public opt-in" a natural per-course filter difference between the two surfaces. Do not merge the two controllers — the base-class difference (auth vs guest) is load-bearing; unify the view layer instead.


2. Recommended architecture

2.1 Public portal (browse + take free courses, no demo data)

  • One catalog card partial (courses/portal/_card.php, shell-less) consumed by both Course_portal and University. Card = thumbnail (real column, local fallback) + title + short description + meta-badge row (category, level, N modules) + state ribbon (FreeEnrolled/Continue).
  • Real server-side filtering via query string (?q=&category=&level=&page=) → controller reads GET → Courses_model::get_details() builds WHERE/LIKE → server-renders the grid. Progressive enhancement: a thin AJAX branch renders just the grid partial. Delete the fake .active-toggle script.
  • Course detail gains a real curriculum outline (modules → lessons from learn_module), an honest meta strip (level, #modules, enrolled count, "Certificate on completion"), loses the fake rating/labels.
  • Enroll flow fix: enroll() success redirect my_courseslearn/learn_player/course/{encoded id} so one click lands in the first lesson. Highest-leverage single change.

2.2 Course visibility (internal default / public opt-in / future paid)

Three independent axes on learn_courses:

Axis Column Now Values
Publish state status (exists) draft / active / inactive
Audience visibility (ADD NOW) internal (default) / public
Price price + currency (ADD LATER) deferred free = derived (price IS NULL OR 0)

Enforcement (all within the already-tenant-switched DB): public University shows a course only when is_catalog_public = 1 AND visibility='public' AND status='active'. In-app Course_portal shows all active courses (internal is the in-app superset). Don't overload status; don't store is_free; don't build a cross-tenant marketplace yet.

2.3 Authoring editor upgrade (additive)

Keep existing modal editors for deep content; put a single-page curriculum tree on top.

  • Blocker first: add position INT to learn_module (+ lesson/exam tables), backfilled from id; ORDER BY position ASC, id ASC; one reorder action per orderable entity (SQL in model).
  • Outline UX: collapsible sections with nested lessons/quizzes; inline add/rename; SortableJS drag-reorder; deep edit opens the existing modal.
  • Simplify the model: default one implicit group per module (AI path already does general_group); hide groups behind an "advanced" toggle. Reframe "discussions" as "lessons" (label change).
  • Quiz builder: one inline exam builder POSTing the same shape the AI blueprint assembles; add missing explanation field; per-exam pass_mark.
  • Libraries: exactly two — SortableJS + Summernote (already present). Avoid Quill (Delta ≠ HTML). Don't bolt SortableJS onto appTable DataTables.
  • Reuse: extract Learn_ai::_persist_course() / _persist_question() into a shared service so AI, manual builder, and future import share one writer.

2.4 Certificates (templates now, JS designer later)

  • Now — ready-made fill-in-the-blank templates: 3-4 built-in HTML/CSS themes (Classic / Modern / Formal seal / Corporate landscape). Author picks a template, uploads a logo, types company + signatory name/title, picks accent color + orientation. No coordinates. Render via the existing mpdf pipeline (same code that makes payslips/invoices). Wire up the dead pdf_path (render on demand; persist only for email attachments).
  • Verification: expose verify() on the public University GuestController (/university/verify/{certificate_no}); add a QR (get_qrcode_image()) to that URL. Snapshot recipient name + course title onto the cert row at issuance (a later name change must not alter an issued credential).
  • Later — JS designer: only when free-form placement is demanded. Konva.js → layout JSON (mm X/Y, Moodle mod_customcert model) → absolutely-positioned <div>s in the same mpdf HTML — PDF engine/verify/QR unchanged, only the authoring surface.
  • mpdf gotchas: strip charset= from the QR data-URI; landscape needs ['format'=>'A4-L']; full-bleed needs margin_*=0.

2.5 Points / progress (gamification)

One ledger table + one denormalized total, awarded synchronously. Defer badges/levels/leaderboard/streaks (pure functions of the total — add later, zero schema change).

  • Idempotency by construction: UNIQUE (user_id, event, source_type, source_id)INSERT ... ON DUPLICATE KEY IGNORE. Never check-then-insert.
  • Award at existing write points (synchronous, no cron): lesson complete, quiz pass (best attempt only), course complete (big award — same 100% hook that issues the cert), certificate earned. Amounts config-driven (lesson 10, quiz 25, course 100, cert 50).
  • Progress stays computed (don't store %). Write ledger + total in one transaction; cheap reconcile detects drift.
  • Surface: points chip on My Courses cards + a "My Points" profile tab (mirror my_certificates_tab) + History list. Leaderboard deferred (per-tenant + opt-in when built).

3. Data model changes (minimal; idempotent + reversible; run against every tenant DB)

All follow the audit-block order (created_by, date_created, updated_at TIMESTAMP, deleted TINYINT), guard with SHOW COLUMNS/SHOW INDEX before ALTER, provide down(), use a real local timestamp prefix (Get-Date -Format "yyyyMMddHHmmss"), register in migrations_courses.

Drift risk: learn_courses, learn_module, learn_group, learn_exams, learn_questions, learn_choices, learn_discussions, learn_students, learn_categories have no CREATE-TABLE migration in the repo (they ship in the tenant install dump). Every new field is a new module migration (ALTER), each SHOW COLUMNS first.

3.1 learn_courses — ALTER

Column Type Phase Purpose
visibility ENUM('internal','public') NOT NULL DEFAULT 'internal' 1 Audience axis.
thumbnail VARCHAR(255) NULL (or serialized file blob per Certifications) 1 Real card image; removes all no-image.jpg.
difficulty ENUM('easy','intermediate','advanced') NULL 1 Makes the level filter real (else drop the buttons).
points_reward INT NULL DEFAULT 0 2 Author-set completion points (optional).
certificate_template_id INT NULL 3 Per-course template pick.
price / currency DECIMAL(10,2) NULL / VARCHAR(3) deferred Paid phase; free derived.

3.2 Courses_model::get_details() — extend $options

Add filters mirroring the existing statuses FIND_IN_SET pattern: search (LIKE title/description), category_id, difficulty, visibility (passed only by University), real limit/offset, and a companion get_count(). All SQL in the model. Use the 2-arg get_array_value($options, "key") form (3-arg silently returns "").

3.3 learn_module (+ lesson/exam tables) — ALTER

position INT NOT NULL DEFAULT 0 (drag-drop ordering; backfill from id). Add to learn_exams: pass_mark TINYINT NULL (falls back to PASS_PERCENT), optionally time_limit, shuffle, max_attempts.

3.4 New table learn_points_ledger

id, uuid, user_id INT, points INT (signed),
event VARCHAR(50)        -- lesson_complete|quiz_pass|course_complete|certificate_earned
source_type VARCHAR(30)  -- module|exam|course|certificate
source_id INT, course_id INT NULL, note VARCHAR(255) NULL,
created_by, date_created, updated_at, deleted
UNIQUE (user_id, event, source_type, source_id)
INDEX (user_id, deleted)

Denormalized total: points INT NOT NULL DEFAULT 0 on learn_students (increment in the same transaction). Learn_points_model extends Crud_model with award(...), get_total($user_id), get_history($user_id). save() is by-reference — named var, never a literal.

3.5 New table learn_certificate_templates

id, uuid, name VARCHAR(155), template_key VARCHAR(50),
logo TEXT NULL, company_name VARCHAR(155) NULL,
signatory_name VARCHAR(155) NULL, signatory_title VARCHAR(155) NULL,
signature_image TEXT NULL, accent_color VARCHAR(20) NULL,
orientation ENUM('portrait','landscape') DEFAULT 'landscape',
background_image TEXT NULL,
created_by, date_created, updated_at, deleted

Snapshot columns on learn_certificates: recipient_name VARCHAR(155) NULL, course_title VARCHAR(255) NULL, template_id INT NULL, written by _issue_certificate(). (Reuse reserved pdf_path.)


4. Phased delivery plan (ordered by value-to-effort)

Phase 0 — Strip demo data + make filters real (quick win) — S/M

Goal: the public portal shows only real data; filters actually filter.

  • Files: views/courses/portal/index.php + view.php (delete the demo blocks in §1.2), views/courses/university/index.php (:27 + make cards <a>), Courses_model.php (add search/category_id/difficulty/limit/offset + get_count()), Course_portal::index() (read GET, pass through, AJAX grid branch), new shell-less courses/portal/_card.php, learn_lang.php (new keys), a bundled local placeholder asset. Migration adds thumbnail/difficulty/visibility; authoring fields in courses/modal_form.php.
  • Acceptance: no i.pravatar.cc/ito-group.com/href="#"/fake-rating strings remain; category + search filters change the grid server-side; every new string is a verified lang() key; full page in #page-content, AJAX grid partial shell-less.

Phase 1 — Unified public portal + enroll fix + visibility — M

Goal: guests browse (public) → log in → land in the lesson; internal-by-default.

  • Files: enroll() redirect → player (Course_portal.php:85); University::index() filters visibility='public' AND is_catalog_public; add public course-detail action + guest Enroll CTA with login-return-URL; both surfaces consume _card.php; courses/modal_form.php gets a visibility Select2 (default Internal); curriculum outline in portal/view.php.
  • Acceptance: a logged-out visitor on a public tenant browses + views detail + is routed login→enroll→player; an internal course never appears on /university even when the tenant is public; in-app portal still shows all active courses.

Phase 2 — Points + progress — M

  • Files: migration (learn_points_ledger + learn_students.points), Learn_points_model.php, award calls in Learn_player::complete / Learn_attempts::submit / cert issuance, points chip in _card.php/my_courses, "My Points" profile tab (mirror my_certificates_tab), History view.
  • Acceptance: completing a lesson/quiz/course credits points once (replays never double-award — UNIQUE index); "My Points" shows total + history; ledger SUM reconciles to the denormalized total.

Phase 3 — Certificate templates (fill-in-the-blank) + PDF + public verify — M

  • Files: migration (learn_certificate_templates + cert snapshot columns), Learn_certificate_templates CRUD (fork the Certifications upload form), 3-4 built-in theme partials, certificates/render.php (interpolate {{learner_name}}/{{course_title}}/{{qr}}/{{logo}}…), mpdf output action, public University::verify() + QR, _issue_certificate() snapshots.
  • Acceptance: author brands a template (logo + 2 names + color) with no coordinates; learner downloads a vector PDF; /university/verify/{no} returns Valid + name/course/date; QR resolves to it; issued certs keep their issued-under name after a rename.

Phase 4 — Authoring editor upgrade (single-page builder) — L

  • Files: migration (position on modules/lessons/exams; pass_mark on exams), reorder actions + model methods, builder view replacing the Modules DataTable, SortableJS wiring, Summernote direct-init, shared _persist_course/_persist_question service extracted from Learn_ai, inline exam builder, explanation field, default-one-group simplification, "discussions"→"lessons" relabel.
  • Acceptance: an author builds a full course (modules → lessons → quiz) without leaving one page; drag-reorder persists; type-appropriate inline answer editors; AI-generated draft edits in the same builder; runtime order matches the builder.

Phase 5 — Paid courses (deferred) — L

price/currency, enroll() gate (price>0 → order/entitlement flow), catalog "price/Free" ribbon, payment integration + order/entitlement table. Keep self_enroll_enabled as its own axis. Do not pre-build.

Phase 6 — Certificate JS designer (deferred) — L

Konva.js → layout JSON (mm X/Y) → absolutely-positioned divs in the same mpdf HTML. Only if free-form placement is demanded.


5. Public portal spec (for the HTML mockup)

Global: brand teal #1d97a6 CTAs/links; charcoal #58585a headers; dark/light via semantic tokens (never hardcode colors); full pages in #page-content .p20; all copy from lang(). Real data only.

  • Catalog: header band = hero_headline + hero_subhead (from settings, NOT "UNDERSTAND YOUR ROLE"). Filter bar = debounced search + Category pills + Level pills, each sets ?q=&category=&level=&page=. Grid of _card.php cards: thumbnail (local fallback), title, short desc, category, difficulty, module_count, enrolled count, state ribbon (FreeEnrolled/Continue). Real pager, no "Display More". No testimonials, no fake ratings — only trust signal is enrolled count.
  • Course detail: hero (title, desc, instructor, enrolled count, Enroll for free CTA); curriculum outline (modules → lessons); meta strip (level, #modules, enrolled, "Certificate on completion"); 4 recommended cards. States: not-enrolled / enrolled (Continue) / completed (View certificate).
  • Take / enroll: one click → straight into the player's first lesson; guest → login with return-URL, enrollment auto-runs post-auth.
  • My Courses: enrolled cards with progress bar + points chip + Continue/Completed.
  • Certificate: branded template (logo, company + signatory names, accent color, orientation) filled from learn_certificate_templates + snapshotted recipient/course/date/certificate_no + QR to public verify. On-screen preview = same HTML fed to mpdf for Download PDF.

6. Risks & simplicity guardrails

Do NOT build yet (defer explicitly):

  • Paid courses / payments — no buyer exists; ship free-only. price is purely additive later.
  • Cross-tenant course marketplace — hard under DB-per-tenant; the visibility flag doesn't block a future clone/central-catalog approach.
  • JS certificate designer (Konva) — 3-4 fill-in-the-blank templates cover "author pastes logo + names + color." Keep the {{variable}} contract stable.
  • Badges / levels / leaderboard / login-streak points — pure functions of the stored total; add as read-time thresholds later.
  • Ratings / reviews / testimonials — no backing subsystem; faking them erodes B2B trust. Ship enrolled count only.
  • A 4-level authoring tree — 2 real levels (Section → Lesson + Quiz) match 90% of products; default one implicit group per module.

Simplicity guardrails:

  • Delete mock data, don't stub it. Half-measures leave a template-demo look.
  • Filter on the server, never client-side. Client filtering leaks internal/unpublished courses and fights conventions. Wire real WHERE clauses before "polishing" filter buttons.
  • Reuse the runtime — the take-a-course chain needs zero rework; the demo problem is isolated to two view files.
  • One card partial, one catalog model — a placeholder fix lands once.
  • Idempotency for points is a DB guarantee (UNIQUE + INSERT IGNORE), never check-then-insert.
  • Certificates: render on demand; cache to pdf_path only for email; snapshot the recipient name at issuance.
  • CI3 conventions, always: no raw SQL outside models; Crud_model::save() by-reference; deleted=0 on every query; real-timestamp idempotent reversible migrations with SHOW COLUMNS/SHOW INDEX guards; audit-block order; lang() for every new string (verify the key); 2-arg get_array_value; multi-tenant migrations run against every tenant DB; Select2 init scoped to select.select2; full pages in #page-content, partials shell-less.
  • Two libraries max for authoring (SortableJS + Summernote); avoid Quill.
  • Both conditions for public (is_catalog_public AND visibility='public').

Recommended order: Phase 0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → defer 5–6.

Was this guide helpful?

Report a content problem