docs: sp4 spec + plan (certs, domain swap)
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
# SP4 — Certificates + Domain Swap Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Moodle-generated certificates emailed automatically on course completion, celebrated in the app; then the app takes moodle.saillant.cc and Moodle moves to lms-admin.saillant.cc.
|
||||||
|
|
||||||
|
**Stack/Repo:** unchanged. Branch `feat/sp4-certs-domain` off main. Runner as before. Secrets only in env files (Tower `/home/clems/formations-app.env`; SMTP creds read from ES `/home/clems/electron-rare-site/mail-api.env` at provisioning time, stored in Moodle config — never in git).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Moodle certificates provisioning
|
||||||
|
|
||||||
|
**Files:** Create `ops/provision-certs.php`; update `ops/README.md`; host crontab on Tower.
|
||||||
|
|
||||||
|
- [ ] **1.1** `ops/provision-certs.php` (CLI, idempotent, style of provision-ws.php). For each course id>1:
|
||||||
|
- find the customcert cm + the 4 quiz cmids (modules join, like export script);
|
||||||
|
- set `course_modules.availability` on the customcert cm to `{"op":"&","c":[{"type":"completion","cm":<quizcmid>,"e":1},...×4],"showc":[true,true,true,true]}` (e:1 = COMPLETE). Use `$DB->set_field` + `rebuild_course_cache($courseid, true)`.
|
||||||
|
- set customcert.emailstudents=1 (`$DB->set_field('customcert','emailstudents',1,...)`).
|
||||||
|
Then SMTP config from env vars (script reads SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/SMTP_FROM passed via docker exec -e): `set_config('smtphosts', host.':'.port); set_config('smtpsecure','tls'); set_config('smtpuser',...); set_config('smtppass',...); set_config('noreplyaddress', SMTP_FROM); set_config('allowedemaildomains','');`. Print summary.
|
||||||
|
- [ ] **1.2** Run on Tower (SMTP values from ES `sudo grep -E 'SMTP_|ICLOUD' /home/clems/electron-rare-site/mail-api.env` — From = the iCloud account address, NOT contact@lelectronrare.fr; the 550 trap).
|
||||||
|
- [ ] **1.3** Cron: on Tower `crontab -l` + add idempotently: `*/2 * * * * docker exec moodle php /var/www/html/admin/cli/cron.php >/dev/null 2>&1`. Run once manually; expect task list output incl. `email_certificate_task` scheduled (it ships enabled by default — verify in mdl_task_scheduled, enable if disabled).
|
||||||
|
- [ ] **1.4 PROOF**: pick test user = wsreader (id 3, enrolled): temporarily set its email to a real mailbox? NO — instead: override the 4 quiz completions for wsreader on course 5 (freertos) via the existing MOODLE_SYNC_TOKEN + core_completion_override_activity_completion_status (cmids from QuizBank rows), run cron, then check `SELECT * FROM mdl_customcert_issues WHERE userid=3` (row = issuance works) and the cron output mentions the email task (send will fail or go to wsreader@saillant.cc — irrelevant, issuance row is the proof; report the email task log line). REVERT the 4 overrides (newstate=0) and DELETE the test issue row.
|
||||||
|
- [ ] **1.5** README section (what the script sets, SMTP source, cron line, rollback notes) + commit `feat: certificate issuing provisioning`.
|
||||||
|
|
||||||
|
### Task 2: App certificate page + hub CTA (TDD on the predicate)
|
||||||
|
|
||||||
|
**Files:** Create `src/lib/completion.ts` + test, `src/pages/cours/[slug]/certificat.astro`; modify hub page.
|
||||||
|
|
||||||
|
- [ ] **2.1** `src/lib/completion.ts`: `export function courseComplete(modules: {chapters:{...}[]}[], reads: ReadMark[], quizzes: Quiz[], attempts: QuizAttempt[]): {complete: boolean; chaptersRead: number; chaptersTotal: number; quizzesPassed: number; quizzesTotal: number}` — pure; complete = every module fully read (isBookComplete) AND every quiz passed (best attempt isPassed). Tests (≥4): complete, missing chapters, missing quiz, empty.
|
||||||
|
- [ ] **2.2** `/cours/<slug>/certificat`: published slug else 404-rewrite; anon → redirect connexion. Load modules/reads/quizzes/attempts (try/catch → 503 friendly). If complete: dark celebration page — big copper nucleus glow (pure CSS, no three.js), « Félicitations ! », course title, `X chapitres · 4 quiz réussis`, panel « Votre certificat officiel est envoyé par email à <user.email> (vérifiez vos indésirables). Généré par notre plateforme de formation. », contact link mailto:contact@lelectronrare.fr, « Retour au cours ». If incomplete: progress view (chapters n/N, quiz m/4, links to first missing chapter + first unpassed quiz).
|
||||||
|
- [ ] **2.3** Hub: when user && complete → copper card/CTA « 🎓 Certificat disponible → /cours/<slug>/certificat » under the progress lines; when user && !complete → muted line « Certificat : termine les chapitres et les 4 quiz ». Reuse courseComplete (single computation with already-loaded data).
|
||||||
|
- [ ] **2.4** Runner green → commit `feat: certificate celebration page`.
|
||||||
|
|
||||||
|
### Task 3: Domain swap
|
||||||
|
|
||||||
|
**Files (repo):** modify `src/middleware.ts` (formations→moodle 301 host redirect). **Infra:** traefik dynamic files on ES, CF ingress, Moodle config.php (Tower volume), Tower env file, Keycloak client, lelectronrare.fr links.
|
||||||
|
|
||||||
|
- [ ] **3.1** middleware.ts: at top of onRequest — `if (ctx.url.hostname === 'formations.saillant.cc') return ctx.redirect('https://moodle.saillant.cc' + ctx.url.pathname + ctx.url.search, 301);`. Commit `feat: formations host 301 to moodle domain`.
|
||||||
|
- [ ] **3.2** Keycloak: add redirect URI `https://moodle.saillant.cc/auth/callback` + webOrigin to client `formations-app` (kcadm update, KEEP existing URIs).
|
||||||
|
- [ ] **3.3** lms-admin.saillant.cc: traefik `lms-admin.yml` on ES (→ http://100.78.6.122:8095, websecure letsencrypt) + CF tunnel ingress (recipe). Verify 200 via --resolve BEFORE wwwroot change (Moodle may redirect to wwwroot — a 3xx to moodle.saillant.cc is fine at this stage).
|
||||||
|
- [ ] **3.4** Moodle wwwroot: on Tower edit config.php inside volume (`docker exec moodle sed -i "s|https://moodle.saillant.cc|https://lms-admin.saillant.cc|" /var/www/html/config.php`) + purge caches (`php admin/cli/purge_caches.php`). Verify https://lms-admin.saillant.cc 200 + login page assets load from lms-admin.
|
||||||
|
- [ ] **3.5** App env on Tower: `MOODLE_BASE_URL=https://lms-admin.saillant.cc`, `APP_BASE_URL=https://moodle.saillant.cc` (sed in /home/clems/formations-app.env). NOTE: MOODLE_WS_TOKEN/MOODLE_SYNC_TOKEN remain valid (tokens are host-independent).
|
||||||
|
- [ ] **3.6** Swap moodle.saillant.cc: edit ES traefik `moodle.yml` service url → `http://100.78.6.122:8096`. `formations.yml` unchanged (still → 8096; the app's middleware now 301s that host). Pull+rebuild app on Tower (gets middleware + env).
|
||||||
|
- [ ] **3.7** lelectronrare.fr: replace `formations.saillant.cc` → `moodle.saillant.cc` in the 3 link files (src/pages/formations.astro, src/components/Formations.astro, src/components/Footer.astro), commit, push gitea+origin, rebuild image (--network=host) + recreate container on ES (compose chez clems).
|
||||||
|
|
||||||
|
### Task 4: E2E
|
||||||
|
|
||||||
|
- [ ] App on moodle.saillant.cc: `/` 200 catalogue; `/cours/kicad-makers` 200 with data-hub; chapter 200; `/connexion` 302 Keycloak; legacy `/course/view.php?id=3` 301 → /cours/kicad-makers.
|
||||||
|
- [ ] lms-admin.saillant.cc: 200, Moodle login page; WS sanity: hub renders (BFF green via lms-admin); `login/token.php` reachable.
|
||||||
|
- [ ] formations.saillant.cc/* → 301 moodle.saillant.cc same path.
|
||||||
|
- [ ] certificat page: anon → 302; (logged user manual: incomplete → progress view).
|
||||||
|
- [ ] lelectronrare.fr/formations links → moodle.saillant.cc (curl grep).
|
||||||
|
- [ ] Moodle cron ran in last 5 min (`mdl_config_plugins lastcron` or task log).
|
||||||
|
- [ ] Merge ff-only → main, push; memories update.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- Order: app-side first, swap last; each infra step has a verify; rollback = revert 2 traefik files + wwwroot sed back.
|
||||||
|
- 550 trap: From = iCloud-owned address (noreplyaddress), documented.
|
||||||
|
- Availability lockdown closes the "cert downloadable by anyone enrolled" hole found in probe.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Design: SP4 — certificates + domain swap
|
||||||
|
|
||||||
|
Date: 2026-06-11
|
||||||
|
Status: program decisions locked (brainstorm 2026-06-10); technicals below
|
||||||
|
probed on the live systems.
|
||||||
|
|
||||||
|
## Probed ground truth
|
||||||
|
|
||||||
|
- 6 customcert activities ("Certificat de réussite", cmids 55-60), one per
|
||||||
|
course, currently with NO availability restriction (any enrolled user
|
||||||
|
could download — must be locked down).
|
||||||
|
- Course completion criteria already configured (48 rows, criteriatype 4 =
|
||||||
|
activity completion — the 8 activities per course).
|
||||||
|
- Moodle auth = email,oauth2 (FER issuer only). Learners are app-SSO only,
|
||||||
|
no Moodle password.
|
||||||
|
- Moodle container has NO cron runner (restored compose is web+db only) —
|
||||||
|
scheduled tasks (certificate emailing) never run today.
|
||||||
|
- No SMTP configured in Moodle. House SMTP = iCloud (smtp.mail.me.com:587,
|
||||||
|
creds in mail-api env on ES) — **From MUST be the iCloud-owned address**
|
||||||
|
(550 otherwise; same trap as lelectronrare.fr contact form).
|
||||||
|
|
||||||
|
## Decision: email-issued certificates (no learner Moodle login)
|
||||||
|
|
||||||
|
The certificate stays 100% Moodle-generated (customcert = program
|
||||||
|
decision) but is DELIVERED BY EMAIL via Moodle's native
|
||||||
|
`email_certificate_task`. Learners never need to log into Moodle:
|
||||||
|
|
||||||
|
1. App pushes activity completions (books SP2, quizzes SP3) → Moodle
|
||||||
|
course completion aggregates (criteria already set).
|
||||||
|
2. customcert activities get availability conditions = completion of the
|
||||||
|
course's 4 quizzes (DB/PHP provisioning) and `emailstudents=1`.
|
||||||
|
3. Moodle cron (added) runs the issue+email task: eligible learners get
|
||||||
|
the PDF at their Keycloak email.
|
||||||
|
4. The app's celebration page tells them it's on its way.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Moodle provisioning (`ops/provision-certs.php` + infra)
|
||||||
|
- Set per-course customcert `cm.availability` = AND-completion of that
|
||||||
|
course's 4 quiz cmids (state completed-pass or completed: use state 1).
|
||||||
|
- `emailstudents=1` on the 6 customcerts; `verifyany` left as is.
|
||||||
|
- SMTP: set Moodle config smtphosts/smtpuser/smtppass/smtpsecure +
|
||||||
|
`noreplyaddress` = the iCloud-owned From; secrets read from env at run.
|
||||||
|
- Cron: host crontab on Tower (`*/2 * * * * docker exec moodle php
|
||||||
|
/var/www/html/admin/cli/cron.php`), idempotent install.
|
||||||
|
- Proof: complete a test user's quizzes via override → run cron →
|
||||||
|
`mdl_customcert_issues` row exists and the email task logs a send.
|
||||||
|
|
||||||
|
### 2. App — certificate page + CTA
|
||||||
|
- Course complete (app-side definition: ALL chapters read AND 4 quizzes
|
||||||
|
passed) → hub shows a copper "Certificat" card; `/cours/<slug>/certificat`
|
||||||
|
(login required): celebration screen (big copper glow, course name,
|
||||||
|
score recap), text "Votre certificat officiel est généré par notre
|
||||||
|
plateforme et envoyé à <email> sous quelques minutes", note to check
|
||||||
|
spam, contact link. Not complete → page shows progress and what's
|
||||||
|
missing (n chapters, m quizzes).
|
||||||
|
- No customcert WS exists → the app does NOT query issue state (YAGNI);
|
||||||
|
delivery is Moodle's job.
|
||||||
|
|
||||||
|
### 3. Domain swap
|
||||||
|
- NEW `lms-admin.saillant.cc` → Moodle (traefik ES → Tower:8095 + CF
|
||||||
|
ingress); Moodle `$CFG->wwwroot` switched to it (config.php in the
|
||||||
|
moodle-html volume); admin keeps password login.
|
||||||
|
- `moodle.saillant.cc` traefik route RE-POINTED → the app (Tower:8096);
|
||||||
|
app takes the domain: `APP_BASE_URL=https://moodle.saillant.cc`,
|
||||||
|
Keycloak client `formations-app` gains the new redirect URI,
|
||||||
|
`MOODLE_BASE_URL=https://lms-admin.saillant.cc` (WS + pluginfile URLs
|
||||||
|
follow wwwroot automatically).
|
||||||
|
- `formations.saillant.cc` kept as 301 → moodle.saillant.cc (tiny
|
||||||
|
middleware host check in the app).
|
||||||
|
- lelectronrare.fr links updated formations.saillant.cc → moodle.saillant.cc
|
||||||
|
(3 files), rebuilt + redeployed.
|
||||||
|
- Old learner deep-links `/course/view.php?id=N` on moodle.saillant.cc now
|
||||||
|
hit the app middleware → already 301 to /cours/<slug> (load-bearing as
|
||||||
|
planned in SP1).
|
||||||
|
|
||||||
|
## Order of operations (swap is last, app-first is safe)
|
||||||
|
Certs provisioning → app cert page (works on formations domain) →
|
||||||
|
domain swap → E2E.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
Cron/email failures: Moodle task logs; app celebration is informational
|
||||||
|
(no hard dependency). Swap rollback: traefik routes are 2 small files;
|
||||||
|
wwwroot rollback documented in ops/README.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
vitest: course-complete predicate. E2E: cert page anon→connexion; incomplete
|
||||||
|
→ progress view; domain swap: app 200 on moodle.saillant.cc, Moodle 200 on
|
||||||
|
lms-admin, WS still green (hub renders), formations 301, site links updated,
|
||||||
|
legacy /course/view.php 301. Manual owner: real email reception.
|
||||||
Reference in New Issue
Block a user