diff --git a/src/lib/grist.test.ts b/src/lib/grist.test.ts new file mode 100644 index 0000000..030b8c1 --- /dev/null +++ b/src/lib/grist.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { mapRecords, isBookComplete, type ReadMark } from './grist'; + +describe('mapRecords', () => { + it('maps a realistic Grist records payload', () => { + const raw = { + records: [ + { id: 1, fields: { user_sub: 'kc-1', email: 'a@b.c', course_slug: 'kicad-makers', module_n: 1, chapitre_n: 2, lu_le: 1760000000, moodle_synced: false } }, + { id: 2, fields: { user_sub: 'kc-1', email: 'a@b.c', course_slug: 'kicad-makers', module_n: 2, chapitre_n: 3, lu_le: 1760000100, moodle_synced: true } }, + ], + }; + expect(mapRecords(raw)).toEqual([ + { id: 1, course_slug: 'kicad-makers', module_n: 1, chapitre_n: 2, moodle_synced: false }, + { id: 2, course_slug: 'kicad-makers', module_n: 2, chapitre_n: 3, moodle_synced: true }, + ]); + }); + + it('coerces missing or odd-typed fields safely', () => { + const raw = { records: [{ id: 7, fields: { module_n: '3', chapitre_n: null, moodle_synced: 0 } }] }; + expect(mapRecords(raw)).toEqual([ + { id: 7, course_slug: '', module_n: 3, chapitre_n: 0, moodle_synced: false }, + ]); + }); + + it('returns empty array for empty records', () => { + expect(mapRecords({ records: [] })).toEqual([]); + }); +}); + +describe('isBookComplete', () => { + const mark = (m: number, c: number): ReadMark => + ({ id: 0, course_slug: 's', module_n: m, chapitre_n: c, moodle_synced: false }); + + it('true when every chapter of the module is read', () => { + expect(isBookComplete([mark(1, 1), mark(1, 2), mark(1, 3)], 1, 3)).toBe(true); + }); + + it('false when there is a hole', () => { + expect(isBookComplete([mark(1, 1), mark(1, 3)], 1, 3)).toBe(false); + }); + + it('false on empty reads', () => { + expect(isBookComplete([], 1, 3)).toBe(false); + }); + + it('only counts chapters of the requested module', () => { + const reads = [mark(2, 1), mark(2, 2)]; + expect(isBookComplete(reads, 1, 2)).toBe(false); + expect(isBookComplete(reads, 2, 2)).toBe(true); + }); +}); diff --git a/src/lib/grist.ts b/src/lib/grist.ts new file mode 100644 index 0000000..084b494 --- /dev/null +++ b/src/lib/grist.ts @@ -0,0 +1,68 @@ +const BASE = () => process.env.GRIST_BASE_URL || 'https://grist.saillant.cc'; +const KEY = () => process.env.GRIST_API_KEY || ''; +const DOC = () => process.env.GRIST_DOC_ID || ''; + +export interface ReadMark { + course_slug: string; + module_n: number; + chapitre_n: number; + moodle_synced: boolean; + id: number; +} + +async function grist(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE()}/api/docs/${DOC()}${path}`, { + ...init, + headers: { Authorization: `Bearer ${KEY()}`, 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + signal: AbortSignal.timeout(6000), + }); + if (!res.ok) throw new Error(`grist ${path}: HTTP ${res.status}`); + return res.json(); +} + +export function mapRecords(raw: { records: { id: number; fields: Record }[] }): ReadMark[] { + return raw.records.map((r) => ({ + id: r.id, + course_slug: String(r.fields.course_slug ?? ''), + module_n: Number(r.fields.module_n ?? 0), + chapitre_n: Number(r.fields.chapitre_n ?? 0), + moodle_synced: Boolean(r.fields.moodle_synced), + })); +} + +export async function getReads(sub: string, courseSlug: string): Promise { + const filter = encodeURIComponent(JSON.stringify({ user_sub: [sub], course_slug: [courseSlug] })); + return mapRecords(await grist(`/tables/Progression/records?filter=${filter}`)); +} + +export async function addRead(sub: string, email: string, courseSlug: string, moduleN: number, chapitreN: number): Promise { + const existing = await getReads(sub, courseSlug); + if (existing.some((r) => r.module_n === moduleN && r.chapitre_n === chapitreN)) return; // idempotent + await grist('/tables/Progression/records', { + method: 'POST', + body: JSON.stringify({ + records: [{ + fields: { + user_sub: sub, email, course_slug: courseSlug, + module_n: moduleN, chapitre_n: chapitreN, + lu_le: Math.floor(Date.now() / 1000), // Grist DateTime accepts epoch seconds + moodle_synced: false, + }, + }], + }), + }); +} + +export async function markSynced(ids: number[]): Promise { + if (!ids.length) return; + await grist('/tables/Progression/records', { + method: 'PATCH', + body: JSON.stringify({ records: ids.map((id) => ({ id, fields: { moodle_synced: true } })) }), + }); +} + +export function isBookComplete(reads: ReadMark[], moduleN: number, chapterCount: number): boolean { + const seen = new Set(reads.filter((r) => r.module_n === moduleN).map((r) => r.chapitre_n)); + for (let i = 1; i <= chapterCount; i++) if (!seen.has(i)) return false; + return true; +} diff --git a/src/lib/moodle-sync.ts b/src/lib/moodle-sync.ts new file mode 100644 index 0000000..4858c54 --- /dev/null +++ b/src/lib/moodle-sync.ts @@ -0,0 +1,5 @@ +/** Best-effort Moodle completion sync — STUB, implemented in SP2 Task 4. */ +export async function syncToMoodle( + _u: { sub: string; email: string; name: string }, + _slug: string, +): Promise {} diff --git a/src/pages/api/lu.ts b/src/pages/api/lu.ts new file mode 100644 index 0000000..0001766 --- /dev/null +++ b/src/pages/api/lu.ts @@ -0,0 +1,49 @@ +import type { APIRoute } from 'astro'; +import { verifySession, SESSION_COOKIE } from '../../lib/session'; +import { bySlug } from '../../config/courses'; +import { addRead } from '../../lib/grist'; +import { syncToMoodle } from '../../lib/moodle-sync'; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const POST: APIRoute = async ({ request, cookies, redirect }) => { + const user = verifySession(cookies.get(SESSION_COOKIE)?.value); + if (!user) return json(401, { error: 'auth' }); + + const ct = request.headers.get('content-type') ?? ''; + const isForm = ct.includes('application/x-www-form-urlencoded') || ct.includes('multipart/form-data'); + + let slug = '', moduleRaw = '', chapitreRaw = ''; + try { + if (isForm) { + const form = await request.formData(); + slug = String(form.get('slug') ?? ''); + moduleRaw = String(form.get('module') ?? ''); + chapitreRaw = String(form.get('chapitre') ?? ''); + } else { + const body = (await request.json()) as Record; + slug = String(body.slug ?? ''); + moduleRaw = String(body.module ?? ''); + chapitreRaw = String(body.chapitre ?? ''); + } + } catch { + return json(400, { error: 'body' }); + } + + const course = bySlug(slug); + const m = Number(moduleRaw); + const c = Number(chapitreRaw); + if (!course || !course.published || !Number.isInteger(m) || m < 1 || !Number.isInteger(c) || c < 1) + return json(400, { error: 'params' }); + + const back = `/cours/${slug}/module-${m}/chapitre-${c}`; + try { + await addRead(user.sub, user.email, slug, m, c); + } catch (err) { + console.error('[lu] grist addRead failed:', (err as Error).message); + return isForm ? redirect(`${back}?erreur=progression`, 303) : json(502, { error: 'grist' }); + } + syncToMoodle({ sub: user.sub, email: user.email, name: user.name }, slug).catch(() => {}); + return isForm ? redirect(back, 303) : json(200, { ok: true }); +}; diff --git a/src/pages/cours/[slug]/[module]/[chapitre].astro b/src/pages/cours/[slug]/[module]/[chapitre].astro index 9b20b63..581f0a6 100644 --- a/src/pages/cours/[slug]/[module]/[chapitre].astro +++ b/src/pages/cours/[slug]/[module]/[chapitre].astro @@ -5,6 +5,7 @@ import { bySlug } from '../../../../config/courses'; import { getCourseBooks, getChapterHtml } from '../../../../lib/moodle'; import { sanitizeHtml } from '../../../../lib/sanitize'; import { verifySession, SESSION_COOKIE } from '../../../../lib/session'; +import { getReads } from '../../../../lib/grist'; const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value); const course = bySlug(Astro.params.slug ?? ''); @@ -26,6 +27,16 @@ let html; try { html = sanitizeHtml(await getChapterHtml(chapter.fileUrl)); } catch { return new Response('Chapitre momentanément indisponible.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); } +let dejaLu = false; +let gristDown = false; +if (user) { + try { + const reads = await getReads(user.sub, course.slug); + dejaLu = reads.some((r) => r.module_n === mi + 1 && r.chapitre_n === ci + 1); + } catch { gristDown = true; } +} +const progressionErreur = Astro.url.searchParams.get('erreur') === 'progression' || gristDown; + const prev = ci > 0 ? `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci}` : mi > 0 ? `/cours/${course.slug}/module-${mi}/chapitre-${modules[mi - 1].chapters.length}` : null; const next = ci < mod.chapters.length - 1 ? `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci + 2}` @@ -42,7 +53,28 @@ const next = ci < mod.chapters.length - 1 ? `/cours/${course.slug}/module-${mi +

{chapter.title}

-
+ {progressionErreur && ( +
+ Progression momentanément indisponible — réessayez dans quelques minutes. +
+ )} +
+ {!user ? ( +

+ Se connecter pour suivre votre progression. +

+ ) : dejaLu ? ( + Chapitre lu ✓ + ) : ( +
+ + + + +
+ )} +
+