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.htmlin 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.activetoggle only, filters nothing):143-155— category filter buttons (client-side only; JS at:258-266just toggles class):166— external placeholder thumbnailhttps://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.ccavatars), 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— sameno-image.jpgin Recommended Courses:103-111— dead footer links
modules/Learn/views/courses/university/index.php
:27— sameno-image.jpgplaceholder; 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 toCourse_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 bothCourse_portalandUniversity. Card = thumbnail (real column, local fallback) + title + short description + meta-badge row (category, level, N modules) + state ribbon (Free→Enrolled/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 redirectmy_courses→learn/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
positionINT tolearn_module(+ lesson/exam tables), backfilled fromid;ORDER BY position ASC, id ASC; onereorderaction 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
explanationfield; per-exampass_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 publicUniversityGuestController (/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_customcertmodel) → 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 needsmargin_*=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_categorieshave no CREATE-TABLE migration in the repo (they ship in the tenant install dump). Every new field is a new module migration (ALTER), eachSHOW COLUMNSfirst.
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(addsearch/category_id/difficulty/limit/offset+get_count()),Course_portal::index()(read GET, pass through, AJAX grid branch), new shell-lesscourses/portal/_card.php,learn_lang.php(new keys), a bundled local placeholder asset. Migration addsthumbnail/difficulty/visibility; authoring fields incourses/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 verifiedlang()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()filtersvisibility='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.phpgets avisibilitySelect2 (default Internal); curriculum outline inportal/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
/universityeven 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 inLearn_player::complete/Learn_attempts::submit/ cert issuance, points chip in_card.php/my_courses, "My Points" profile tab (mirrormy_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_templatesCRUD (fork theCertificationsupload form), 3-4 built-in theme partials,certificates/render.php(interpolate{{learner_name}}/{{course_title}}/{{qr}}/{{logo}}…), mpdf output action, publicUniversity::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 (
positionon modules/lessons/exams;pass_markon exams),reorderactions + model methods, builder view replacing the Modules DataTable, SortableJS wiring, Summernote direct-init, shared_persist_course/_persist_questionservice extracted fromLearn_ai, inline exam builder,explanationfield, 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.phpcards: thumbnail (local fallback), title, short desc, category, difficulty,module_count, enrolled count, state ribbon (Free→Enrolled/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.
priceis purely additive later. - Cross-tenant course marketplace — hard under DB-per-tenant; the
visibilityflag 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_pathonly for email; snapshot the recipient name at issuance. - CI3 conventions, always: no raw SQL outside models;
Crud_model::save()by-reference;deleted=0on every query; real-timestamp idempotent reversible migrations withSHOW COLUMNS/SHOW INDEXguards; audit-block order;lang()for every new string (verify the key); 2-argget_array_value; multi-tenant migrations run against every tenant DB; Select2 init scoped toselect.select2; full pages in#page-content, partials shell-less. - Two libraries max for authoring (SortableJS + Summernote); avoid Quill.
- Both conditions for public (
is_catalog_publicANDvisibility='public').
Recommended order: Phase 0 → Phase 1 → Phase 2 → Phase 3 → Phase 4 → defer 5–6.