From 4d5afa23c2baec4e953694af0452e1035418197f Mon Sep 17 00:00:00 2001 From: Thijs Date: Wed, 3 Jun 2026 21:08:32 +0200 Subject: [PATCH 1/2] fix(intra): refresh ongoing regular-cursus levels to catch silent Intra recomputes Intra recomputes `cursus_users.level` server-side as a side-effect of project_user / scale_team events, but the recompute does NOT reliably bump `cursus_users.updated_at`. The incremental sync uses `filter[updated_at]`, so those level changes are silently dropped. The author already worked around this for piscines via a force-refetch loop, but the same workaround was never extended to common-core (cursus 1 and 21). Result: a common-core student's level can freeze at the value from the last event that *did* happen to touch the timestamp, even though Intra's own UI shows the correct value. Real-world reproduction (Codam 2023 cohort student): - Resume bumped level to 11.16 (synced OK). - ft_transcendence was validated, taking the level to 12.69 on Intra. - Three more project validations followed. - Hero stayed stuck at 11.16 the entire time. Fix: - Extract the piscine force-refetch loop into a reusable `refreshOngoingCursusLevels()` helper. - Tighten the 'ongoing' filter to `begin_at <= syncDate AND (end_at IS NULL OR end_at >= syncDate)` so it handles both piscines (end_at always set) and common-core (end_at typically NULL). - Make the 'delete missing rows' behaviour opt-in via `deleteMissing`. Kept `true` for piscines (a pisciner can unregister via Apply, where that semantics make sense). Set to `false` for REGULAR_CURSUS_IDS, since silently dropping a common-core row because the API briefly omitted it is too risky. - Call the helper for REGULAR_CURSUS_IDS in addition to the existing piscines pass. --- src/intra/cursus.ts | 152 ++++++++++++++++++++++++++++---------------- 1 file changed, 97 insertions(+), 55 deletions(-) diff --git a/src/intra/cursus.ts b/src/intra/cursus.ts index b7a6b12..55785fa 100644 --- a/src/intra/cursus.ts +++ b/src/intra/cursus.ts @@ -131,6 +131,92 @@ const setupCursuses = async function(): Promise { // }); } +/** + * Re-fetch level/grade from Intra for all currently-ongoing cursus_users in the + * given cursus ids, bypassing the incremental updated_at filter. + * + * Why: Intra recomputes `cursus_users.level` server-side as a side-effect of + * project_user / scale_team events, but the recompute does NOT reliably bump + * `cursus_users.updated_at`. This means the normal incremental sync + * (`filter[updated_at]`) misses level changes, and the local DB freezes at the + * value from the last event that *did* happen to touch the timestamp. + * + * "Ongoing" = begin_at <= syncDate AND (end_at IS NULL OR end_at >= syncDate). + * This covers both piscine cursus_users (end_at always set) and common-core + * cursus_users (end_at typically NULL until graduation/alumnization). + * + * @param api Fast42 instance. + * @param syncDate Current sync timestamp. + * @param cursusIds Cursus IDs to refresh (e.g. PISCINE_CURSUS_IDS or REGULAR_CURSUS_IDS). + * @param opts.deleteMissing + * If true, cursus_users that the API no longer returns are + * deleted from the local DB (used for piscines, where a + * pisciner can unregister via Apply). For regular cursuses + * this should be false: silently dropping a row because the + * API briefly omitted it is too risky. + */ +export const refreshOngoingCursusLevels = async function( + api: Fast42, + syncDate: Date, + cursusIds: number[], + opts: { deleteMissing: boolean }, +): Promise { + if (cursusIds.length === 0) { + return; + } + + const ongoing = await prisma.cursusUser.findMany({ + where: { + begin_at: { lte: syncDate }, + OR: [ + { end_at: null }, + { end_at: { gte: syncDate } }, + ], + cursus_id: { in: cursusIds }, + }, + }); + + if (ongoing.length === 0) { + return; + } + + // Chunk in batches of 100 to stay within Intra's filter-by-id URL limits. + const chunks: typeof ongoing[] = []; + for (let i = 0; i < ongoing.length; i += 100) { + chunks.push(ongoing.slice(i, i + 100)); + } + + for (const chunk of chunks) { + const fresh = await fetchMultiple42ApiPages(api, `/cursus_users`, { + 'filter[id]': chunk.map(c => c.id).join(','), + }); + + for (const cursusUser of fresh) { + try { + await prisma.cursusUser.update({ + where: { id: cursusUser.id }, + data: { + level: cursusUser.level, + grade: cursusUser.grade ? cursusUser.grade : null, + updated_at: new Date(cursusUser.updated_at), + }, + }); + } + catch (err) { + console.error(`Error updating cursus_user ${cursusUser.user?.login ?? '?'} - ${cursusUser.cursus?.name ?? '?'}: ${err}`); + } + } + + if (opts.deleteMissing) { + const missing = chunk.filter(c => !fresh.find((f: any) => f.id === c.id)); + for (const m of missing) { + console.warn(`Cursus_user ${m.id} of user ${m.user_id} was not returned by the API. Removing it from the database.`); + await prisma.cursusUser.delete({ where: { id: m.id } }); + } + } + } +}; + export const syncCursus = async function(api: Fast42, syncDate: Date): Promise { // Make sure cursuses exist in the database for relations await setupCursuses(); @@ -195,63 +281,19 @@ export const syncCursus = async function(api: Fast42, syncDate: Date): Promise cursusUser.id).join(','), - }); + console.log("Checking for ongoing regular (common-core) cursuses..."); - for (const cursusUser of ongoingPiscineCursusesAPI) { - try { - await prisma.cursusUser.update({ - where: { - id: cursusUser.id, - }, - data: { - level: cursusUser.level, - grade: cursusUser.grade ? cursusUser.grade : null, - updated_at: new Date(cursusUser.updated_at), - }, - }); - } - catch (err) { - console.error(`Error updating cursus_user ${cursusUser.user.login} - ${cursusUser.cursus.name}: ${err}`); - } - } - - // Find the cursus_users that were not returned by the API - // This can happen if a pisciner unregistered from the piscine through Apply - const missingCursusUsers = chunk.filter(cursusUser => !ongoingPiscineCursusesAPI.find(cursusUserAPI => cursusUserAPI.id === cursusUser.id)); - for (const missingCursusUser of missingCursusUsers) { - console.warn(`Cursus_user ${missingCursusUser.id} of user ${missingCursusUser.user_id} was not returned by the API. Removing it from the database.`); - await prisma.cursusUser.delete({ - where: { - id: missingCursusUser.id, - }, - }); - } - } + // Same workaround applies to common-core cursus_users: silent server-side + // recomputes of `level` mean the incremental `filter[updated_at]` pull misses them. + // We do NOT delete missing rows here: a common-core student dropping out of + // the curriculum is rare, and Intra may temporarily omit a row for unrelated + // reasons; better to leave the existing record in place than to delete it. + await refreshOngoingCursusLevels(api, syncDate, REGULAR_CURSUS_IDS, { deleteMissing: false }); // Mark synchronization as complete by updating the last_synced_at field await prisma.synchronization.upsert({ From cbb2e5ea87ca9f1fb14a636c8622ad858916377d Mon Sep 17 00:00:00 2001 From: Thijs Date: Sat, 6 Jun 2026 11:55:57 +0200 Subject: [PATCH 2/2] fix(intra): delete missing common-core cursus_users on refresh Applicants who register for the Common Core via Apply and then unregister have their cursus_user deleted on Intra; mirror that by setting deleteMissing: true for REGULAR_CURSUS_IDS. --- src/intra/cursus.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/intra/cursus.ts b/src/intra/cursus.ts index 55785fa..ea19768 100644 --- a/src/intra/cursus.ts +++ b/src/intra/cursus.ts @@ -290,10 +290,10 @@ export const syncCursus = async function(api: Fast42, syncDate: Date): Promise