diff --git a/api/package.json b/api/package.json index b739a80..b5ef619 100644 --- a/api/package.json +++ b/api/package.json @@ -11,7 +11,9 @@ "dev": "tsc && tsc-alias && node ./built/api/src/index.js", "prod": "tsc && tsc-alias && node ./built/api/src/index.js", "build": "tsc && tsc-alias", - "seed": "node ./scripts/seed.js" + "seed": "node ./scripts/seed.js", + "backfill:qualifications:dry": "node ./scripts/backfillQualifications.js --dry-run", + "backfill:qualifications": "node ./scripts/backfillQualifications.js" }, "dependencies": { "@rsol/hashmig": "^1.0.7", diff --git a/api/scripts/backfillQualifications.js b/api/scripts/backfillQualifications.js new file mode 100644 index 0000000..c7324b7 --- /dev/null +++ b/api/scripts/backfillQualifications.js @@ -0,0 +1,237 @@ +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(); + } +})();