From f8b1811b74c73b450849eb39fd5f0953f11e7dc7 Mon Sep 17 00:00:00 2001 From: ajdj100 Date: Fri, 22 May 2026 09:00:32 -0400 Subject: [PATCH] Added support for challenges and tweaked qual awarding rules --- .../20260522120000-course-event-challenge.js | 45 ++++++++ ...0522120000-course-event-challenge-down.sql | 2 + ...260522120000-course-event-challenge-up.sql | 2 + api/scripts/backfillQualifications.js | 105 +++++++++++++++--- api/src/services/db/CourseSerivce.ts | 8 +- api/src/services/db/qualificationService.ts | 75 +++++++++---- shared/schemas/trainingReportSchema.ts | 1 + shared/types/course.ts | 2 + .../trainingReport/trainingReportForm.vue | 45 +++++++- ui/src/pages/TrainingReport.vue | 28 +++-- 10 files changed, 259 insertions(+), 54 deletions(-) create mode 100644 api/migrations/20260522120000-course-event-challenge.js create mode 100644 api/migrations/sqls/20260522120000-course-event-challenge-down.sql create mode 100644 api/migrations/sqls/20260522120000-course-event-challenge-up.sql diff --git a/api/migrations/20260522120000-course-event-challenge.js b/api/migrations/20260522120000-course-event-challenge.js new file mode 100644 index 0000000..2b40fa5 --- /dev/null +++ b/api/migrations/20260522120000-course-event-challenge.js @@ -0,0 +1,45 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20260522120000-course-event-challenge-up.sql'); + return new Promise(function(resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function(err, data) { + if (err) return reject(err); + console.log('received data: ' + data); + resolve(data); + }); + }).then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20260522120000-course-event-challenge-down.sql'); + return new Promise(function(resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function(err, data) { + if (err) return reject(err); + console.log('received data: ' + data); + resolve(data); + }); + }).then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + version: 1, +}; diff --git a/api/migrations/sqls/20260522120000-course-event-challenge-down.sql b/api/migrations/sqls/20260522120000-course-event-challenge-down.sql new file mode 100644 index 0000000..ff2b096 --- /dev/null +++ b/api/migrations/sqls/20260522120000-course-event-challenge-down.sql @@ -0,0 +1,2 @@ +ALTER TABLE course_events + DROP COLUMN is_challenge; diff --git a/api/migrations/sqls/20260522120000-course-event-challenge-up.sql b/api/migrations/sqls/20260522120000-course-event-challenge-up.sql new file mode 100644 index 0000000..94ec023 --- /dev/null +++ b/api/migrations/sqls/20260522120000-course-event-challenge-up.sql @@ -0,0 +1,2 @@ +ALTER TABLE course_events + ADD COLUMN is_challenge TINYINT(1) NOT NULL DEFAULT 0 AFTER hasQual; diff --git a/api/scripts/backfillQualifications.js b/api/scripts/backfillQualifications.js index c7324b7..f781876 100644 --- a/api/scripts/backfillQualifications.js +++ b/api/scripts/backfillQualifications.js @@ -104,36 +104,107 @@ function key(memberId, courseId) { } const scoped = buildScopeClause(args); + const evidenceScopedClauses = []; + const evidenceScopedParams = []; + if (args.courseId !== null) { + evidenceScopedClauses.push("e.course_id = ?"); + evidenceScopedParams.push(args.courseId); + } + if (args.memberId !== null) { + evidenceScopedClauses.push("ca.attendee_id = ?"); + evidenceScopedParams.push(args.memberId); + } + const evidenceScopedSql = evidenceScopedClauses.length + ? ` AND ${evidenceScopedClauses.join(" AND ")}` + : ""; - const passRows = await conn.query( + const evidenceRows = await conn.query( `SELECT ca.attendee_id AS member_id, e.course_id, e.id AS course_event_id, - e.event_date + e.event_date, + IFNULL(e.is_challenge, 0) AS is_challenge, + IFNULL(e.hasBookwork, 0) AS event_has_bookwork, + IFNULL(e.hasQual, 0) AS event_has_qual, + ca.passed_bookwork, + ca.passed_qual, + IFNULL(c.hasBookwork, 0) AS course_has_bookwork, + IFNULL(c.hasQual, 0) AS course_has_qual FROM course_events e + INNER JOIN courses c ON c.id = e.course_id 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} + ${evidenceScopedSql} ORDER BY ca.attendee_id ASC, e.course_id ASC, e.event_date DESC, e.id DESC;`, - scoped.params + evidenceScopedParams ); - 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), + const aggregateByPair = new Map(); + for (const row of evidenceRows) { + const memberId = Number(row.member_id); + const courseId = Number(row.course_id); + const mapKey = key(memberId, courseId); + const bookworkEvidence = Number(row.event_has_bookwork) === 1 && Number(row.passed_bookwork) === 1; + const qualEvidence = Number(row.event_has_qual) === 1 && Number(row.passed_qual) === 1; + const challengeQualEvidence = Number(row.is_challenge) === 1 && Number(row.event_has_qual) === 1 && Number(row.passed_qual) === 1; + + const existing = aggregateByPair.get(mapKey) || { + memberId, + courseId, + courseHasBookwork: Number(row.course_has_bookwork) === 1, + courseHasQual: Number(row.course_has_qual) === 1, + hasBookworkPass: false, + hasQualPass: false, + hasChallengeQualPass: false, + latestEvidence: null, + }; + + existing.hasBookworkPass = existing.hasBookworkPass || bookworkEvidence; + existing.hasQualPass = existing.hasQualPass || qualEvidence; + existing.hasChallengeQualPass = existing.hasChallengeQualPass || challengeQualEvidence; + + if (bookworkEvidence || qualEvidence || challengeQualEvidence) { + const nextLatest = { courseEventId: Number(row.course_event_id), eventDate: row.event_date, + }; + + if (!existing.latestEvidence) { + existing.latestEvidence = nextLatest; + } else { + const currentDate = new Date(existing.latestEvidence.eventDate).getTime(); + const nextDate = new Date(nextLatest.eventDate).getTime(); + if (nextDate > currentDate || (nextDate === currentDate && nextLatest.courseEventId > existing.latestEvidence.courseEventId)) { + existing.latestEvidence = nextLatest; + } + } + } + + aggregateByPair.set(mapKey, existing); + } + + const latestByPair = new Map(); + for (const [mapKey, pair] of aggregateByPair.entries()) { + const hasBookwork = pair.courseHasBookwork; + const hasQual = pair.courseHasQual; + + let qualifies = false; + if (hasBookwork && hasQual) { + qualifies = (pair.hasBookworkPass && pair.hasQualPass) || pair.hasChallengeQualPass; + } else if (hasBookwork && !hasQual) { + qualifies = pair.hasBookworkPass; + } else if (!hasBookwork && hasQual) { + qualifies = pair.hasQualPass; + } + + if (qualifies && pair.latestEvidence) { + latestByPair.set(mapKey, { + memberId: pair.memberId, + courseId: pair.courseId, + courseEventId: pair.latestEvidence.courseEventId, + eventDate: pair.latestEvidence.eventDate, }); } } @@ -164,7 +235,7 @@ function key(memberId, courseId) { courseId: args.courseId, }, actorId: args.actorId, - historicalPassingRows: passRows.length, + historicalPassingRows: evidenceRows.length, activePairsComputed: latestByPair.size, currentlyActivePairs: currentRows.length, wouldDeactivate, diff --git a/api/src/services/db/CourseSerivce.ts b/api/src/services/db/CourseSerivce.ts index 3c6de8f..cbcdca8 100644 --- a/api/src/services/db/CourseSerivce.ts +++ b/api/src/services/db/CourseSerivce.ts @@ -86,7 +86,7 @@ export async function insertCourseEvent(event: CourseEventDetails): Promise { const eventRows = await con.query( - `SELECT e.event_date, e.hasBookwork, e.hasQual, c.id AS course_id + `SELECT e.event_date, c.hasBookwork, c.hasQual, c.id AS course_id FROM course_events e INNER JOIN courses c ON c.id = e.course_id WHERE e.id = ?;`, @@ -85,28 +85,49 @@ async function getEventQualificationContext(con: any, courseEventId: number): Pr }; } -function attendeePassed(attendee: CourseAttendee, hasBookwork: boolean, hasQual: boolean): boolean { - if (attendee.attendee_role_id !== 2) { - return false; +async function getLatestPassingCourseEvent( + con: any, + memberId: number, + courseId: number, + hasBookwork: boolean, + hasQual: boolean +): Promise<{ course_event_id: number, event_date: Date } | null> { + const evidenceRows = await con.query( + `SELECT + MAX(CASE WHEN IFNULL(e.hasBookwork, 0) = 1 AND ca.passed_bookwork = 1 THEN 1 ELSE 0 END) AS has_bookwork_pass, + MAX(CASE WHEN IFNULL(e.hasQual, 0) = 1 AND ca.passed_qual = 1 THEN 1 ELSE 0 END) AS has_qual_pass, + MAX(CASE WHEN IFNULL(e.is_challenge, 0) = 1 AND IFNULL(e.hasQual, 0) = 1 AND ca.passed_qual = 1 THEN 1 ELSE 0 END) AS has_challenge_qual_pass + FROM course_events e + INNER JOIN course_attendees ca ON ca.course_event_id = e.id + WHERE e.course_id = ? + AND ca.attendee_id = ? + AND ca.attendee_role_id = 2 + AND (e.deleted IS NULL OR e.deleted = 0);`, + [courseId, memberId] + ); + + if (!evidenceRows.length) { + return null; } + const hasBookworkPass = Number(evidenceRows[0].has_bookwork_pass) === 1; + const hasQualPass = Number(evidenceRows[0].has_qual_pass) === 1; + const hasChallengeQualPass = Number(evidenceRows[0].has_challenge_qual_pass) === 1; + + let hasPassingRecord = false; if (hasBookwork && hasQual) { - return attendee.passed_bookwork && attendee.passed_qual; + hasPassingRecord = (hasBookworkPass && hasQualPass) || hasChallengeQualPass; + } else if (hasBookwork && !hasQual) { + hasPassingRecord = hasBookworkPass; + } else if (!hasBookwork && hasQual) { + hasPassingRecord = hasQualPass; } - if (hasBookwork && !hasQual) { - return attendee.passed_bookwork; + if (!hasPassingRecord) { + return null; } - if (!hasBookwork && hasQual) { - return attendee.passed_qual; - } - - return false; -} - -async function getLatestPassingCourseEvent(con: any, memberId: number, courseId: number): Promise<{ course_event_id: number, event_date: Date } | null> { - const rows = await con.query( + const latestRows = await con.query( `SELECT 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 @@ -115,22 +136,22 @@ async function getLatestPassingCourseEvent(con: any, memberId: number, courseId: AND 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) + (IFNULL(e.hasBookwork, 0) = 1 AND ca.passed_bookwork = 1) + OR (IFNULL(e.hasQual, 0) = 1 AND ca.passed_qual = 1) + OR (IFNULL(e.is_challenge, 0) = 1 AND IFNULL(e.hasQual, 0) = 1 AND ca.passed_qual = 1) ) ORDER BY e.event_date DESC, e.id DESC LIMIT 1;`, [courseId, memberId] ); - if (!rows.length) { + if (!latestRows.length) { return null; } return { - course_event_id: Number(rows[0].course_event_id), - event_date: new Date(rows[0].event_date), + course_event_id: Number(latestRows[0].course_event_id), + event_date: new Date(latestRows[0].event_date), }; } @@ -165,7 +186,7 @@ export async function syncQualificationsForCourseEvent(courseEventId: number, ac const expectedMembers = new Set(); for (const attendee of context.attendees) { - if (attendeePassed(attendee, context.hasBookwork, context.hasQual)) { + if (attendee.attendee_role_id === 2) { expectedMembers.add(attendee.attendee_id); } } @@ -191,7 +212,13 @@ export async function syncQualificationsForCourseEvent(courseEventId: number, ac for (let i = 0; i < impactedList.length; i++) { const memberId = impactedList[i]; - const latestPass = await getLatestPassingCourseEvent(con, memberId, context.courseId); + const latestPass = await getLatestPassingCourseEvent( + con, + memberId, + context.courseId, + context.hasBookwork, + context.hasQual + ); if (latestPass) { await con.query( diff --git a/shared/schemas/trainingReportSchema.ts b/shared/schemas/trainingReportSchema.ts index 7dd7baa..903d1b6 100644 --- a/shared/schemas/trainingReportSchema.ts +++ b/shared/schemas/trainingReportSchema.ts @@ -11,6 +11,7 @@ export const courseEventAttendeeSchema = z.object({ export const trainingReportSchema = z.object({ id: z.number().int().positive().optional(), course_id: z.number({ invalid_type_error: "Must select a training" }).int(), + is_challenge: z.boolean().default(false), event_date: z .string() .refine( diff --git a/shared/types/course.ts b/shared/types/course.ts index e615db8..30c7a15 100644 --- a/shared/types/course.ts +++ b/shared/types/course.ts @@ -18,6 +18,7 @@ export interface CourseEventDetails { course_id: number | null; // FK → courses.id event_type: number | null; // FK → event_types.id event_date: Date; // datetime (not nullable) + is_challenge?: boolean; guilded_event_id: number | null; @@ -88,4 +89,5 @@ export interface CourseEventSummary { date: string; created_by: number; created_by_name: string; + is_challenge?: boolean; } \ No newline at end of file diff --git a/ui/src/components/trainingReport/trainingReportForm.vue b/ui/src/components/trainingReport/trainingReportForm.vue index 5c13296..f6e625d 100644 --- a/ui/src/components/trainingReport/trainingReportForm.vue +++ b/ui/src/components/trainingReport/trainingReportForm.vue @@ -34,6 +34,7 @@ const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({ validateOnMount: false, initialValues: { course_id: null, + is_challenge: false, event_date: "", remarks: "", attendees: [], @@ -59,6 +60,17 @@ watch(() => values.course_id, (newCourseId, oldCourseId) => { }); }); +watch(() => values.is_challenge, (isChallenge) => { + if (!isChallenge) { + return; + } + + values.attendees.forEach((a, index) => { + // @ts-ignore + setFieldValue(`attendees[${index}].passed_bookwork`, false); + }); +}); + const submitForm = handleSubmit(onSubmit); function toMySQLDateTime(date: Date): string { @@ -92,6 +104,15 @@ async function onSubmit(vals) { const { remove, push, fields } = useFieldArray('attendees'); const selectedCourse = computed(() => { return trainings.value?.find(c => c.id == values.course_id) }) +const bookworkDisabledMessage = computed(() => { + if (values.is_challenge) { + return "Bookwork is waived for challenge reports"; + } + if (!selectedCourse.value?.hasBookwork) { + return "This course does not have bookwork"; + } + return ""; +}); const trainings = ref(null); const members = ref(null); @@ -195,6 +216,24 @@ const filteredMembers = computed(() => { + +
+ + + + Challenge +
+ +

Mark report as challenge

+
+ + Challenge waives bookwork, but qualification pass is still required. + +
+
+
+
@@ -335,9 +374,9 @@ const filteredMembers = computed(() => {
- - + diff --git a/ui/src/pages/TrainingReport.vue b/ui/src/pages/TrainingReport.vue index 67ff981..3e60132 100644 --- a/ui/src/pages/TrainingReport.vue +++ b/ui/src/pages/TrainingReport.vue @@ -34,6 +34,7 @@ import { } from '@/components/ui/pagination' import Tooltip from '@/components/tooltip/Tooltip.vue'; import { CopyLink } from '@/lib/copyLink'; +import Badge from '@/components/ui/badge/Badge.vue'; enum sidePanelState { view, create, closed }; @@ -187,6 +188,10 @@ function formatDate(date: Date | string): string { }); } +function isChallengeReport(report: { is_challenge?: boolean | number | null }): boolean { + return report?.is_challenge === true || Number(report?.is_challenge || 0) === 1; +} + function setPageSize(size: number) { pageSize.value = size pageNum.value = 1; @@ -246,9 +251,12 @@ const expanded = ref(null);