diff --git a/src/lib/moodle-sync.test.ts b/src/lib/moodle-sync.test.ts new file mode 100644 index 0000000..1272912 --- /dev/null +++ b/src/lib/moodle-sync.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { modulesToSync } from './moodle-sync'; +import type { ReadMark } from './grist'; + +let nextId = 100; +const mark = (module_n: number, chapitre_n: number, moodle_synced = false): ReadMark => ({ + id: nextId++, course_slug: 'kicad-makers', module_n, chapitre_n, moodle_synced, +}); +const mod = (id: number, chapterCount: number) => ({ + id, + chapters: Array.from({ length: chapterCount }, (_, i) => ({ title: `c${i + 1}`, fileUrl: '' })), +}); + +describe('modulesToSync', () => { + it('includes a complete module with unsynced rows', () => { + const reads = [mark(1, 1), mark(1, 2)]; + const out = modulesToSync(reads, [mod(27, 2)]); + expect(out).toHaveLength(1); + expect(out[0].mi).toBe(0); + expect(out[0].cmid).toBe(27); + expect(out[0].rowIds.sort()).toEqual(reads.map((r) => r.id).sort()); + }); + + it('excludes a complete module whose rows are all synced', () => { + expect(modulesToSync([mark(1, 1, true), mark(1, 2, true)], [mod(27, 2)])).toEqual([]); + }); + + it('excludes an incomplete module even with unsynced rows', () => { + expect(modulesToSync([mark(1, 1)], [mod(27, 2)])).toEqual([]); + }); + + it('handles multiple modules independently', () => { + const reads = [ + mark(1, 1, true), mark(1, 2, true), // M1 complete, all synced -> out + mark(2, 1), mark(2, 2), // M2 complete, unsynced -> in + mark(3, 1), // M3 incomplete -> out + ]; + const out = modulesToSync(reads, [mod(10, 2), mod(20, 2), mod(30, 2)]); + expect(out.map((t) => t.cmid)).toEqual([20]); + expect(out[0].mi).toBe(1); + }); +}); diff --git a/src/lib/moodle-sync.ts b/src/lib/moodle-sync.ts index 4858c54..ea445a4 100644 --- a/src/lib/moodle-sync.ts +++ b/src/lib/moodle-sync.ts @@ -1,5 +1,104 @@ -/** Best-effort Moodle completion sync — STUB, implemented in SP2 Task 4. */ +import { randomBytes } from 'node:crypto'; +import { wsCall, getCourseBooks, type BookModule } from './moodle'; +import { getReads, markSynced, isBookComplete, type ReadMark } from './grist'; +import { bySlug } from '../config/courses'; + +/** Token of the dedicated `formations_sync` WS service (wssync user). + * Allowed functions ONLY: core_user_get_users_by_field, core_user_create_users, + * core_user_update_users, enrol_manual_enrol_users, core_course_get_contents, + * core_completion_override_activity_completion_status. */ +const TOKEN = () => process.env.MOODLE_SYNC_TOKEN || ''; + +const sync = (fn: string, params: Record) => wsCall(TOKEN(), fn, params); + +const userIdCache = new Map(); // email -> moodle user id +const enrolCache = new Set(); // `${userId}:${courseId}` + +export async function ensureMoodleUser(email: string, name: string): Promise { + const cached = userIdCache.get(email); + if (cached !== undefined) return cached; + + const found = (await sync('core_user_get_users_by_field', { + field: 'email', 'values[0]': email, + })) as { id: number }[]; + if (found.length > 0) { + userIdCache.set(email, found[0].id); + return found[0].id; + } + + const local = (email.split('@')[0] ?? '').toLowerCase().replace(/[^a-z0-9._-]/g, '') || 'apprenant'; + const username = `${local}-${randomBytes(3).toString('hex')}`; + const parts = name.trim().split(/\s+/).filter(Boolean); + const firstname = parts[0] || 'Apprenant'; + const lastname = parts.slice(1).join(' ') || 'X'; + const password = `${randomBytes(12).toString('base64url')}aA1!`; // random + policy classes + const created = (await sync('core_user_create_users', { + 'users[0][username]': username, + 'users[0][password]': password, + 'users[0][firstname]': firstname, + 'users[0][lastname]': lastname, + 'users[0][email]': email, + 'users[0][auth]': 'manual', + })) as { id: number }[]; + userIdCache.set(email, created[0].id); + return created[0].id; +} + +export async function ensureEnrolled(userId: number, courseId: number): Promise { + const key = `${userId}:${courseId}`; + if (enrolCache.has(key)) return; + await sync('enrol_manual_enrol_users', { + 'enrolments[0][roleid]': '5', // student + 'enrolments[0][userid]': String(userId), + 'enrolments[0][courseid]': String(courseId), + }); + enrolCache.add(key); +} + +export interface SyncTarget { mi: number; cmid: number; rowIds: number[]; } + +/** Pure decision logic: modules fully read whose Grist rows aren't all synced. + * `BookModule.id` IS the cmid for completion override. */ +export function modulesToSync( + reads: ReadMark[], + modules: Pick[], +): SyncTarget[] { + const out: SyncTarget[] = []; + modules.forEach((mod, mi) => { + const rows = reads.filter((r) => r.module_n === mi + 1); + if (!rows.some((r) => !r.moodle_synced)) return; // nothing new to sync + if (!isBookComplete(reads, mi + 1, mod.chapters.length)) return; + out.push({ mi, cmid: mod.id, rowIds: rows.map((r) => r.id) }); + }); + return out; +} + +/** Best-effort: mirror fully-read books into Moodle completion. Never throws. */ export async function syncToMoodle( - _u: { sub: string; email: string; name: string }, - _slug: string, -): Promise {} + user: { sub: string; email: string; name: string }, + courseSlug: string, +): Promise { + try { + const course = bySlug(courseSlug); + if (!course) return; + const [reads, modules] = await Promise.all([ + getReads(user.sub, courseSlug), + getCourseBooks(course.id), + ]); + const targets = modulesToSync(reads, modules); + if (!targets.length) return; + + const userId = await ensureMoodleUser(user.email, user.name); + await ensureEnrolled(userId, course.id); + for (const t of targets) { + await sync('core_completion_override_activity_completion_status', { + cmid: String(t.cmid), + userid: String(userId), + newstate: '1', + }); + await markSynced(t.rowIds); + } + } catch (err) { + console.error('[moodle-sync]', (err as Error).message); + } +} diff --git a/src/lib/moodle.ts b/src/lib/moodle.ts index bcddb1d..74e476e 100644 --- a/src/lib/moodle.ts +++ b/src/lib/moodle.ts @@ -7,9 +7,10 @@ const BASE = process.env.MOODLE_BASE_URL || 'https://moodle.saillant.cc'; const TOKEN = process.env.MOODLE_WS_TOKEN || ''; const TTL = Number(process.env.MOODLE_CACHE_TTL_MS || 5 * 60_000); -async function ws(fn: string, params: Record): Promise { +/** Low-level Moodle REST call with an explicit token (shared with moodle-sync). */ +export async function wsCall(token: string, fn: string, params: Record): Promise { const body = new URLSearchParams({ - wstoken: TOKEN, wsfunction: fn, moodlewsrestformat: 'json', ...params, + wstoken: token, wsfunction: fn, moodlewsrestformat: 'json', ...params, }); const res = await fetch(`${BASE}/webservice/rest/server.php`, { method: 'POST', @@ -23,6 +24,8 @@ async function ws(fn: string, params: Record): Promise return data; } +const ws = (fn: string, params: Record) => wsCall(TOKEN, fn, params); + interface StructureItem { title: string; href: string; level: number; subitems?: StructureItem[]; } interface ModContent { diff --git a/src/pages/cours/[slug]/index.astro b/src/pages/cours/[slug]/index.astro index b4e167f..c45d292 100644 --- a/src/pages/cours/[slug]/index.astro +++ b/src/pages/cours/[slug]/index.astro @@ -5,6 +5,8 @@ import Sommaire from '../../../components/Sommaire.astro'; import { bySlug } from '../../../config/courses'; import { getCourseBooks } from '../../../lib/moodle'; import { verifySession, SESSION_COOKIE } from '../../../lib/session'; +import { getReads, type ReadMark } from '../../../lib/grist'; +import { syncToMoodle } from '../../../lib/moodle-sync'; const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value); const course = bySlug(Astro.params.slug ?? ''); @@ -16,6 +18,15 @@ try { } catch { return new Response('Formation momentanément indisponible — réessayez dans quelques minutes.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); } + +let reads: ReadMark[] = []; +if (user) { + try { reads = await getReads(user.sub, course.slug); } catch { reads = []; } + // Best-effort retry of pending Moodle syncs — fire-and-forget, never blocks render. + if (reads.some((r) => !r.moodle_synced)) + syncToMoodle({ sub: user.sub, email: user.email, name: user.name }, course.slug).catch(() => {}); +} + const hubData = { slug: course.slug, modules: modules.map((m, mi) => ({