const dotenv = require("dotenv"); const path = require("path"); const mariadb = require("mariadb"); dotenv.config({ path: path.resolve(process.cwd(), ".env") }); const { DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE, } = process.env; function parseArgs(argv) { const args = { dryRun: false, actorId: null, courseId: null, memberId: null, }; for (let i = 2; i < argv.length; i++) { const token = argv[i]; if (token === "--dry-run") { args.dryRun = true; continue; } if (token.startsWith("--actor=")) { args.actorId = Number(token.split("=")[1]); continue; } if (token.startsWith("--course=")) { args.courseId = Number(token.split("=")[1]); continue; } if (token.startsWith("--member=")) { args.memberId = Number(token.split("=")[1]); continue; } } if (args.actorId !== null && Number.isNaN(args.actorId)) { throw new Error("--actor must be a number"); } if (args.courseId !== null && Number.isNaN(args.courseId)) { throw new Error("--course must be a number"); } if (args.memberId !== null && Number.isNaN(args.memberId)) { throw new Error("--member must be a number"); } return args; } function buildScopeClause(args, tableAlias = "") { const prefix = tableAlias ? `${tableAlias}.` : ""; const clauses = []; const params = []; if (args.courseId !== null) { clauses.push(`${prefix}course_id = ?`); params.push(args.courseId); } if (args.memberId !== null) { clauses.push(`${prefix}member_id = ?`); params.push(args.memberId); } if (!clauses.length) { return { sql: "", params }; } return { sql: ` AND ${clauses.join(" AND ")}`, params, }; } function key(memberId, courseId) { return `${memberId}:${courseId}`; } (async () => { const args = parseArgs(process.argv); const conn = await mariadb.createConnection({ host: DB_HOST, port: Number(DB_PORT), user: DB_USERNAME, password: DB_PASSWORD, database: DB_DATABASE, multipleStatements: false, }); try { const schemaCheck = await conn.query("SHOW COLUMNS FROM members_qualifications LIKE 'course_id';"); if (!schemaCheck.length) { throw new Error("members_qualifications.course_id does not exist. Run qualification migration first."); } const scoped = buildScopeClause(args); const passRows = await conn.query( `SELECT ca.attendee_id AS member_id, e.course_id, e.id AS course_event_id, e.event_date FROM course_events e INNER JOIN course_attendees ca ON ca.course_event_id = e.id WHERE ca.attendee_role_id = 2 AND (e.deleted IS NULL OR e.deleted = 0) AND ( (e.hasBookwork = 1 AND e.hasQual = 1 AND ca.passed_bookwork = 1 AND ca.passed_qual = 1) OR (e.hasBookwork = 1 AND IFNULL(e.hasQual, 0) = 0 AND ca.passed_bookwork = 1) OR (IFNULL(e.hasBookwork, 0) = 0 AND e.hasQual = 1 AND ca.passed_qual = 1) ) ${scoped.sql} ORDER BY ca.attendee_id ASC, e.course_id ASC, e.event_date DESC, e.id DESC;`, scoped.params ); const latestByPair = new Map(); for (const row of passRows) { const mapKey = key(Number(row.member_id), Number(row.course_id)); if (!latestByPair.has(mapKey)) { latestByPair.set(mapKey, { memberId: Number(row.member_id), courseId: Number(row.course_id), courseEventId: Number(row.course_event_id), eventDate: row.event_date, }); } } const currentRows = await conn.query( `SELECT member_id, course_id FROM members_qualifications WHERE active = 1 AND (deleted IS NULL OR deleted = 0) ${scoped.sql};`, scoped.params ); const currentKeys = new Set(currentRows.map((r) => key(Number(r.member_id), Number(r.course_id)))); const nextKeys = new Set(Array.from(latestByPair.keys())); let wouldDeactivate = 0; currentKeys.forEach((k) => { if (!nextKeys.has(k)) { wouldDeactivate++; } }); const summary = { dryRun: args.dryRun, scope: { memberId: args.memberId, courseId: args.courseId, }, actorId: args.actorId, historicalPassingRows: passRows.length, activePairsComputed: latestByPair.size, currentlyActivePairs: currentRows.length, wouldDeactivate, }; if (args.dryRun) { console.log("Qualification backfill dry run summary:"); console.log(JSON.stringify(summary, null, 2)); return; } await conn.beginTransaction(); const deactivateReason = "Backfill recompute from historical training reports"; const deactivateResult = await conn.query( `UPDATE members_qualifications mq SET mq.active = 0, mq.revoked_by_id = ?, mq.revoked_reason = ?, mq.revoked_at = UTC_TIMESTAMP(), mq.updated_at = current_timestamp() WHERE mq.active = 1 AND (mq.deleted IS NULL OR mq.deleted = 0) ${buildScopeClause(args, "mq").sql};`, [args.actorId, deactivateReason].concat(buildScopeClause(args, "mq").params) ); let upserts = 0; for (const item of latestByPair.values()) { await conn.query( `INSERT INTO members_qualifications (member_id, course_id, event_date, active, awarded_by_id, revoked_by_id, revoked_reason, revoked_at, source_course_event_id, deleted) VALUES (?, ?, ?, 1, ?, NULL, NULL, NULL, ?, 0) ON DUPLICATE KEY UPDATE event_date = VALUES(event_date), active = 1, awarded_by_id = VALUES(awarded_by_id), revoked_by_id = NULL, revoked_reason = NULL, revoked_at = NULL, source_course_event_id = VALUES(source_course_event_id), deleted = 0, updated_at = current_timestamp();`, [item.memberId, item.courseId, item.eventDate, args.actorId, item.courseEventId] ); upserts++; } await conn.commit(); console.log("Qualification backfill complete:"); console.log(JSON.stringify({ ...summary, deactivatedRows: deactivateResult.affectedRows, upserts, }, null, 2)); } catch (error) { try { await conn.rollback(); } catch (_ignored) { // No-op rollback failure. } console.error("Qualification backfill failed:"); console.error(error); process.exitCode = 1; } finally { await conn.end(); } })();