feat: grist progression + mark as read
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<any> {
|
||||
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<string, unknown> }[] }): 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<ReadMark[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {}
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
};
|
||||
@@ -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 +
|
||||
</nav>
|
||||
<h1 class="text-3xl md:text-4xl font-semibold tracking-tight text-ink mb-8">{chapter.title}</h1>
|
||||
<article class="chapitre" set:html={html} />
|
||||
<div class="mt-16 pt-8 border-t border-slate-200 flex justify-between gap-4">
|
||||
{progressionErreur && (
|
||||
<div class="mt-12 rounded-xl border border-amber-300 bg-amber-50 px-5 py-3 text-sm text-amber-800">
|
||||
Progression momentanément indisponible — réessayez dans quelques minutes.
|
||||
</div>
|
||||
)}
|
||||
<div class="mt-12">
|
||||
{!user ? (
|
||||
<p class="rounded-xl bg-slate-100 px-5 py-4 text-sm text-slate-500">
|
||||
<a href={`/connexion?next=${encodeURIComponent(Astro.url.pathname)}`} class="text-copper underline hover:text-copper/80">Se connecter</a> pour suivre votre progression.
|
||||
</p>
|
||||
) : dejaLu ? (
|
||||
<span class="inline-block px-5 py-3 rounded-full bg-copper/10 text-copper text-sm font-medium">Chapitre lu ✓</span>
|
||||
) : (
|
||||
<form method="POST" action="/api/lu">
|
||||
<input type="hidden" name="slug" value={course.slug} />
|
||||
<input type="hidden" name="module" value={String(mi + 1)} />
|
||||
<input type="hidden" name="chapitre" value={String(ci + 1)} />
|
||||
<button type="submit" class="px-5 py-3 rounded-full bg-copper text-white text-sm font-medium hover:bg-copper/90 cursor-pointer">Marquer comme lu ✓</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div class="mt-10 pt-8 border-t border-slate-200 flex justify-between gap-4">
|
||||
{prev ? <a href={prev} class="px-5 py-3 rounded-full bg-slate-100 text-ink text-sm font-medium hover:bg-slate-200">← Précédent</a> : <span />}
|
||||
{next ? <a href={next} class="px-5 py-3 rounded-full bg-copper text-white text-sm font-medium hover:bg-copper/90">Suivant →</a>
|
||||
: <a href={`/cours/${course.slug}`} class="px-5 py-3 rounded-full bg-night text-white text-sm font-medium">Terminer le module →</a>}
|
||||
|
||||
Reference in New Issue
Block a user