feat: best-effort moodle completion sync

This commit is contained in:
electron-rare
2026-06-10 23:22:14 +02:00
parent 04f829c0cf
commit f539771c7a
4 changed files with 161 additions and 6 deletions
+42
View File
@@ -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);
});
});
+103 -4
View File
@@ -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<string, string>) => wsCall(TOKEN(), fn, params);
const userIdCache = new Map<string, number>(); // email -> moodle user id
const enrolCache = new Set<string>(); // `${userId}:${courseId}`
export async function ensureMoodleUser(email: string, name: string): Promise<number> {
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<void> {
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<BookModule, 'id' | 'chapters'>[],
): 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<void> {}
user: { sub: string; email: string; name: string },
courseSlug: string,
): Promise<void> {
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);
}
}
+5 -2
View File
@@ -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<string, string>): Promise<unknown> {
/** Low-level Moodle REST call with an explicit token (shared with moodle-sync). */
export async function wsCall(token: string, fn: string, params: Record<string, string>): Promise<unknown> {
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<string, string>): Promise<unknown>
return data;
}
const ws = (fn: string, params: Record<string, string>) => wsCall(TOKEN, fn, params);
interface StructureItem { title: string; href: string; level: number; subitems?: StructureItem[]; }
interface ModContent {
+11
View File
@@ -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) => ({