Index

apps/recallassess/recallassess-api/src/api/shared/email/config/email-best-practices.config.ts

ABSOLUTE_MAX_LIMITS
Type : EmailBestPractices
Default value : { participant: { daily: 5, // Never exceed 5 per day weekly: 20, // Never exceed 20 per week monthly: 60, // Never exceed 60 per month hourly: 2, // Never exceed 2 per hour batching: true, aggregation: false, quietHours: { start: 22, end: 7, }, weekendPolicy: 'skip_non_critical', }, admin: { daily: 10, // Never exceed 10 per day weekly: 50, // Never exceed 50 per week monthly: 200, // Never exceed 200 per month hourly: 5, // Never exceed 5 per hour batching: false, aggregation: true, aggregationThreshold: 3, digestEnabled: true, digestTime: '09:00', }, }

Absolute Maximum Limits (Safety Caps) These are hard limits that cannot be exceeded even with overrides.

EMAIL_BEST_PRACTICES
Type : EmailBestPractices
Default value : { participant: { daily: 2, // Max 2 emails per day (comfortable limit) weekly: 10, // Max 10 emails per week monthly: 30, // Max 30 emails per month hourly: 1, // Max 1 email per hour batching: true, // Enable batching of related emails aggregation: false, // Participants don't need aggregation quietHours: { start: 22, // 10 PM end: 7, // 7 AM }, weekendPolicy: 'skip_non_critical', // Skip non-critical emails on weekends }, admin: { daily: 5, // Max 5 emails per day (aggregated) weekly: 25, // Max 25 emails per week monthly: 80, // Max 80 emails per month hourly: 2, // Max 2 emails per hour batching: false, // Admins don't need batching (they get aggregated) aggregation: true, // Enable aggregation for admin emails aggregationThreshold: 3, // Aggregate if >3 participants digestEnabled: true, // Enable daily digest digestTime: '09:00', // 9 AM daily digest }, }

Best Practices Configuration

Based on user comfort levels:

  • Participants: 1-2 emails per day (comfortable)
  • Admins: 3-5 emails per day (aggregated)

apps/recallassess/recallassess-api/src/config/navigation/admin-navigation.config.ts

adminNavigationConfig
Type : NavigationConfig
Default value : { name: "admin", items: [ { id: "dashboard", title: "Dashboard", type: "basic", icon: "pi pi-home", routerLink: "/dashboard", }, { id: "customer", title: "Customers", type: "collapsable", icon: "pi pi-building", children: [ { id: "customer.company", title: "Companies", type: "basic", icon: "pi pi-building", routerLink: "/module/company/list", }, { id: "customer.participant", title: "Participants", type: "basic", icon: "pi pi-user", routerLink: "/module/participant/list", }, { id: "customer.participant-group", title: "Teams", type: "basic", icon: "pi pi-users", routerLink: "/module/participant-group/list", }, { id: "customer.learning-group", title: "Learning Groups", type: "basic", icon: "pi pi-sitemap", routerLink: "/module/learning-group/list", }, ], }, { id: "course", title: "Courses", type: "collapsable", icon: "pi pi-book", children: [ { id: "course.course", title: "Courses", type: "basic", icon: "pi pi-book", routerLink: "/module/course/list", }, { id: "course.course-module", title: "Modules", type: "basic", icon: "pi pi-bookmark", routerLink: "/module/course-module/list", }, { id: "course.course-module-page", title: "e-Learning Contents", type: "basic", icon: "pi pi-file", routerLink: "/module/course-module-page/list", }, { id: "course.assessment", title: "Assessments (BAT)", type: "basic", icon: "pi pi-check-circle", routerLink: "/module/assessment/list", }, { id: "course.knowledge-review", title: "Knowledge Reviews", type: "basic", icon: "pi pi-clone", routerLink: "/module/knowledge-review/list", }, { id: "course.testimonial", title: "Testimonial", type: "basic", icon: "pi pi-star", routerLink: "/module/testimonial/list", }, ], }, { id: "billing", title: "Licensing & Billing", type: "collapsable", icon: "pi pi-wallet", children: [ { id: "billing.package", title: "Packages", type: "basic", icon: "pi pi-tags", routerLink: "/module/package/list", }, { id: "billing.subscription", title: "Subscriptions", type: "basic", icon: "pi pi-wallet", routerLink: "/module/subscription/list", }, { id: "billing.promo-code", title: "Promo Codes", type: "basic", icon: "pi pi-tag", routerLink: "/module/promo-code/list", }, { id: "billing.invoice", title: "Invoices", type: "basic", icon: "pi pi-file", routerLink: "/module/invoice/list", }, ], }, { id: "communication", title: "Email & Reports", type: "collapsable", icon: "pi pi-envelope", children: [ { id: "communication.email-template", title: "Email Templates", type: "basic", icon: "pi pi-envelope", routerLink: "/module/email-template/list", }, { id: "communication.email-log", title: "Email Logs", type: "basic", icon: "pi pi-send", routerLink: "/module/email-log/list", }, { id: "communication.report-log", title: "Report Logs", type: "basic", icon: "pi pi-file", routerLink: "/module/report-log/list", }, ], }, { id: "admin", title: "System Admin", type: "collapsable", icon: "pi pi-shield", children: [ { id: "admin.user", title: "Users", type: "basic", icon: "pi pi-user", routerLink: "/module/user/list", }, { id: "admin.contact-enquiry", title: "Enquiry Form", type: "basic", icon: "pi pi-inbox", routerLink: "/module/contact-enquiry/list", }, { id: "admin.role", title: "Roles", type: "basic", icon: "pi pi-users", routerLink: "/module/role/list", }, { id: "admin.permission", title: "Permissions", type: "basic", icon: "pi pi-lock", routerLink: "/module/permission/list", }, { id: "admin.system-module", title: "System Modules", type: "basic", icon: "pi pi-th-large", routerLink: "/module/system-module/list", }, { id: "admin.system-setting", title: "System Settings", type: "basic", icon: "pi pi-cog", routerLink: "/module/system-setting/list", }, { id: "admin.valuelist", title: "Valuelist", type: "basic", icon: "pi pi-align-center", routerLink: "/module/valuelist/list", }, { id: "admin.system-log", title: "System Logs", type: "basic", icon: "pi pi-list", routerLink: "/module/system-log/list", }, ], }, { id: "report", title: "Reports", type: "basic", icon: "pi pi-chart-bar", routerLink: "/report/dashboard", }, { id: "setting", title: "Settings", type: "basic", icon: "pi pi-cog", routerLink: "/settings", }, ], }

Admin PWA Navigation Configuration Used by: recallassess-admin-pwa

apps/recallassess/recallassess-api/src/main.ts

appInstance
Type : NestFastifyApplication | null
Default value : null
isShuttingDown
Type : unknown
Default value : false
module
Type : any

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/assessment-answer.migration.ts

assessmentAnswerMigration
Type : MigrationDefinition<LegacyQuizQuestionAnswerRow, AssessmentAnswerPayload>
Default value : { name: "assessment-answer", sourceQuery: ` SELECT qqa.quiz_id, qqa.quiz_question_id, qqa.quiz_question_answer_id, qqa.answer_text, qqa.level, qqa.sort_order FROM quiz_question_answer qqa INNER JOIN quiz_question qq ON qq.quiz_question_id = qqa.quiz_question_id INNER JOIN quiz q ON q.quiz_id = qqa.quiz_id WHERE qqa.quiz_id IN (${LEGACY_QUIZ_IDS_SQL_LIST}) ORDER BY qqa.quiz_id, qqa.quiz_question_id, qqa.sort_order, qqa.quiz_question_answer_id `, transform: async (row, context): Promise<AssessmentAnswerPayload | AssessmentAnswerPayload[] | null> => { // Get quiz to find assessment const quizId = row.quiz_id ? Number(row.quiz_id) : null; if (!quizId) { return null; } // Get mapping directly by quiz_id const mapping = getQuizMappingById(quizId); if (!mapping) { return null; } // Get all course mappings for this quiz (handles quiz 499 for both DNP and DNP-ONLINE) let courseMappings = getCourseMappingsForQuizId(quizId); // Filter by course code if --only-course is specified if (context.onlyCourse) { const filterCourseCode = context.onlyCourse.toUpperCase(); courseMappings = courseMappings.filter( (cm) => cm.courseCode.toUpperCase() === filterCourseCode ); if (courseMappings.length === 0) { return null; // Skip this answer if it doesn't match the filtered course } } // Look up assessment question by matching quiz_question_id from legacy quiz_question // We match by sort_order which corresponds to the quiz_question_id position const quizQuestionId = row.quiz_question_id ? Number(row.quiz_question_id) : null; if (!quizQuestionId) { return null; } // Get sort_order from legacy quiz_question table to match with assessment question const [questionRows] = await context.mysql.query( `SELECT sort_order FROM quiz_question WHERE quiz_question_id = ?`, [quizQuestionId], ); const typedQuestionRows = questionRows as Array<{ sort_order: number }>; if (!typedQuestionRows || typedQuestionRows.length === 0) { return null; } // Convert legacy 1-indexed sort_order to 0-indexed (subtract 1) // Legacy DB uses 1, 2, 3... but new system uses 0, 1, 2... // This must match the conversion in assessment-question.migration.ts const legacySortOrder = typedQuestionRows[0]?.sort_order ? Number(typedQuestionRows[0].sort_order) : null; const questionSortOrder = legacySortOrder !== null && legacySortOrder > 0 ? legacySortOrder - 1 : 0; const courseDelegate = getCourseDelegate(context.prisma); const answerLevel = mapAnswerLevel(row.level); // If quiz maps to multiple courses, return array of payloads if (courseMappings.length > 1) { const payloads: AssessmentAnswerPayload[] = []; for (const courseMapping of courseMappings) { // Look up course const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: courseMapping.courseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping answer - course ${courseMapping.courseCode} not found`); continue; } throw new Error(`Course with code ${courseMapping.courseCode} not found. Run course migration first.`); } // Look up assessment const assessment = await ( context.prisma as unknown as { assessment?: { findFirst: (args: { where: { course_id: number; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).assessment?.findFirst({ where: { course_id: course.id, title: courseMapping.title, }, select: { id: true }, }); if (!assessment) { if (context.dryRun) { console.warn(`⚠️ Skipping answer - assessment "${courseMapping.title}" not found for course ${courseMapping.courseCode}`); continue; } throw new Error(`Assessment "${courseMapping.title}" not found for course ${courseMapping.courseCode}. Run assessments migration first.`); } // Find assessment question by assessment_id + sort_order (matching by quiz_question position) const assessmentQuestion = await ( context.prisma as unknown as { assessmentQuestion?: { findFirst: (args: { where: { assessment_id: number; sort_order: number }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).assessmentQuestion?.findFirst({ where: { assessment_id: assessment.id, sort_order: questionSortOrder, }, select: { id: true }, }); if (!assessmentQuestion) { if (context.dryRun) { console.warn( `⚠️ Skipping answer for ${courseMapping.courseCode} - assessment question not found for quiz_question_id: ${quizQuestionId} (sort_order: ${questionSortOrder})`, ); continue; } else { console.warn( `⚠️ Skipping answer (quiz_question_answer_id: ${row.quiz_question_answer_id}) for ${courseMapping.courseCode} - assessment question not found for quiz_question_id: ${quizQuestionId} (sort_order: ${questionSortOrder})`, ); continue; } } payloads.push({ course_id: course.id, assessment_id: assessment.id, assessment_question_id: assessmentQuestion.id, answer_text: row.answer_text ? String(row.answer_text).trim() : null, answer_level: answerLevel, sort_order: getSortOrderForAnswerLevel(answerLevel, row.sort_order), }); } return payloads.length > 0 ? payloads : null; } // Single course mapping (normal case) const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: mapping.courseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping answer - course ${mapping.courseCode} not found`); return null; } throw new Error(`Course with code ${mapping.courseCode} not found. Run course migration first.`); } // Look up assessment const assessment = await ( context.prisma as unknown as { assessment?: { findFirst: (args: { where: { course_id: number; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).assessment?.findFirst({ where: { course_id: course.id, title: mapping.title, }, select: { id: true }, }); if (!assessment) { if (context.dryRun) { console.warn(`⚠️ Skipping answer - assessment "${mapping.title}" not found`); return null; } throw new Error(`Assessment "${mapping.title}" not found. Run assessments migration first.`); } // Find assessment question by assessment_id + sort_order (matching by quiz_question position) const assessmentQuestion = await ( context.prisma as unknown as { assessmentQuestion?: { findFirst: (args: { where: { assessment_id: number; sort_order: number }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).assessmentQuestion?.findFirst({ where: { assessment_id: assessment.id, sort_order: questionSortOrder, }, select: { id: true }, }); if (!assessmentQuestion) { // Skip answer if question doesn't exist if (context.dryRun) { console.warn( `⚠️ Skipping answer - assessment question not found for quiz_question_id: ${quizQuestionId} (sort_order: ${questionSortOrder})`, ); return null; } else { console.warn( `⚠️ Skipping answer (quiz_question_answer_id: ${row.quiz_question_answer_id}) - assessment question not found for quiz_question_id: ${quizQuestionId} (sort_order: ${questionSortOrder})`, ); return null; } } return { course_id: course.id, assessment_id: assessment.id, assessment_question_id: assessmentQuestion.id, answer_text: row.answer_text ? String(row.answer_text).trim() : null, answer_level: answerLevel, sort_order: getSortOrderForAnswerLevel(answerLevel, row.sort_order), }; }, upsert: async (payload, { prisma }) => { const prismaClient = prisma as unknown as { assessmentAnswer?: { findFirst: (args: { where: { assessment_id: number; assessment_question_id: number; answer_text: string | null; }; }) => Promise<{ id: number } | null>; update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>; create: (args: { data: Record<string, unknown> }) => Promise<unknown>; }; }; if (!prismaClient.assessmentAnswer) { throw new Error( "Prisma client is missing the `assessmentAnswer` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if answer already exists const existing = await prismaClient.assessmentAnswer.findFirst({ where: { assessment_id: payload.assessment_id, assessment_question_id: payload.assessment_question_id, answer_text: payload.answer_text, }, }); const answerData = { course_id: payload.course_id, assessment_id: payload.assessment_id, assessment_question_id: payload.assessment_question_id, answer_text: payload.answer_text, answer_level: payload.answer_level, sort_order: payload.sort_order, }; if (existing) { // Update existing answer await prismaClient.assessmentAnswer.update({ where: { id: existing.id }, data: answerData, }); } else { // Create new answer await prismaClient.assessmentAnswer.create({ data: answerData, }); } }, }
LEGACY_QUIZ_IDS
Type : unknown
Default value : getLegacyQuizIds()
LEGACY_QUIZ_IDS_SQL_LIST
Type : unknown
Default value : LEGACY_QUIZ_IDS.length > 0 ? LEGACY_QUIZ_IDS.join(", ") : "0"

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/assessment.migration.ts

assessmentMigration
Type : MigrationDefinition<LegacyQuizRow, AssessmentPayload>
Default value : { name: "assessment", sourceQuery: ` SELECT quiz_id, title, description FROM quiz WHERE quiz_id IN (${LEGACY_QUIZ_IDS_SQL_LIST}) ORDER BY quiz_id `, transform: async (row, context): Promise<AssessmentPayload | AssessmentPayload[] | null> => { const quizId = row.quiz_id ? Number(row.quiz_id) : null; if (!quizId) { return null; } const mapping = getQuizMappingById(quizId); if (!mapping) { return null; } // Get all course mappings for this quiz (handles quiz 499 for both DNP and DNP-ONLINE) let courseMappings = getCourseMappingsForQuizId(quizId); // Filter by course code if --only-course is specified if (context.onlyCourse) { const filterCourseCode = context.onlyCourse.toUpperCase(); courseMappings = courseMappings.filter( (cm) => cm.courseCode.toUpperCase() === filterCourseCode ); if (courseMappings.length === 0) { return null; // Skip this quiz if it doesn't match the filtered course } } const courseDelegate = getCourseDelegate(context.prisma); // Use description from LMS (legacy quiz table), fallback to mapping if needed const description = (row.description ? String(row.description).trim() || null : null) ?? mapping.description ?? null; // If quiz maps to multiple courses, return array of payloads if (courseMappings.length > 1) { const payloads: AssessmentPayload[] = []; for (const courseMapping of courseMappings) { const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: courseMapping.courseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping assessment "${courseMapping.title}" (quiz_id: ${quizId}) - course ${courseMapping.courseCode} not found`); continue; } throw new Error(`Course with code ${courseMapping.courseCode} not found. Run course migration first.`); } payloads.push({ course_id: course.id, title: courseMapping.title, description, is_published: true, }); } return payloads.length > 0 ? payloads : null; } // Single course mapping (normal case) const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: mapping.courseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping assessment "${mapping.title}" (quiz_id: ${quizId}) - course ${mapping.courseCode} not found`); return null; } throw new Error(`Course with code ${mapping.courseCode} not found. Run course migration first.`); } return { course_id: course.id, title: mapping.title, description, is_published: true, }; }, upsert: async (payload, { prisma }) => { const prismaClient = prisma as unknown as { assessment?: { findFirst: (args: { where: { course_id: number; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; create: (args: { data: Record<string, unknown> }) => Promise<{ id: number }>; update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<{ id: number }>; }; course?: { update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>; }; }; if (!prismaClient.assessment) { throw new Error( "Prisma client is missing the `assessment` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if assessment already exists const existing = await prismaClient.assessment.findFirst({ where: { course_id: payload.course_id, title: payload.title, }, select: { id: true }, }); const assessmentData = { title: payload.title, description: payload.description, course_id: payload.course_id, is_published: payload.is_published, }; let assessment: { id: number }; if (existing) { // Update existing assessment assessment = await prismaClient.assessment.update({ where: { id: existing.id }, data: assessmentData, }); } else { // Create new assessment assessment = await prismaClient.assessment.create({ data: assessmentData, }); } // Update course with assessment_id if (prismaClient.course) { await prismaClient.course.update({ where: { id: payload.course_id }, data: { assessment_id: assessment.id }, }); } }, }
LEGACY_QUIZ_IDS
Type : unknown
Default value : getLegacyQuizIds()
LEGACY_QUIZ_IDS_SQL_LIST
Type : unknown
Default value : LEGACY_QUIZ_IDS.length > 0 ? LEGACY_QUIZ_IDS.join(", ") : "0"

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/assessment-question.migration.ts

assessmentQuestionMigration
Type : MigrationDefinition<LegacyQuizQuestionRow, AssessmentQuestionPayload>
Default value : { name: "assessment-question", sourceQuery: ` SELECT qq.quiz_id, qq.quiz_question_id, qq.question_text, qq.sort_order, qq.training_topic_id, tt.topic_code as course_module_code, tt.title as training_topic_title, q.title as quiz_title FROM quiz_question qq INNER JOIN quiz q ON q.quiz_id = qq.quiz_id LEFT JOIN training_topic tt ON tt.training_topic_id = qq.training_topic_id WHERE qq.quiz_id IN (${LEGACY_QUIZ_IDS_SQL_LIST}) ORDER BY qq.quiz_id, qq.sort_order, qq.quiz_question_id `, transform: async (rowQuiz, context): Promise<AssessmentQuestionPayload | AssessmentQuestionPayload[] | null> => { // Get quiz to find assessment const quizId = rowQuiz.quiz_id ? Number(rowQuiz.quiz_id) : null; if (!quizId) { return null; } // Get mapping directly by quiz_id const mapping = getQuizMappingById(quizId); if (!mapping) { return null; } // Validation: Ensure question_text is not empty const questionText = String(rowQuiz.question_text ?? "").trim(); if (!questionText) { if (context.dryRun) { console.warn(`⚠️ Skipping question - question_text is empty`); return null; } throw new Error(`Question text is required but was empty for quiz_question_id: ${rowQuiz.quiz_question_id}`); } // Convert legacy 1-indexed sort_order to 0-indexed (subtract 1) // Legacy DB uses 1, 2, 3... but new system uses 0, 1, 2... const legacySortOrder = rowQuiz.sort_order ? Number(rowQuiz.sort_order) : null; const sortOrder = legacySortOrder !== null && legacySortOrder > 0 ? legacySortOrder - 1 : 0; // Get all course mappings for this quiz (handles quiz 499 for both DNP and DNP-ONLINE) let courseMappings = getCourseMappingsForQuizId(quizId); // Filter by course code if --only-course is specified if (context.onlyCourse) { const filterCourseCode = context.onlyCourse.toUpperCase(); courseMappings = courseMappings.filter( (cm) => cm.courseCode.toUpperCase() === filterCourseCode ); if (courseMappings.length === 0) { return null; // Skip this question if it doesn't match the filtered course } } const courseDelegate = getCourseDelegate(context.prisma); const moduleCode = rowQuiz.course_module_code; // If quiz maps to multiple courses, return array of payloads if (courseMappings.length > 1) { const payloads: AssessmentQuestionPayload[] = []; for (const courseMapping of courseMappings) { // Look up course const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: courseMapping.courseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping question - course ${courseMapping.courseCode} not found`); continue; } throw new Error(`Course with code ${courseMapping.courseCode} not found. Run course migration first.`); } // Look up assessment by course_id and title const assessment = await ( context.prisma as unknown as { assessment?: { findFirst: (args: { where: { course_id: number; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).assessment?.findFirst({ where: { course_id: course.id, title: courseMapping.title, }, select: { id: true }, }); if (!assessment) { if (context.dryRun) { console.warn(`⚠️ Skipping question - assessment "${courseMapping.title}" not found for course ${courseMapping.courseCode}`); continue; } throw new Error(`Assessment "${courseMapping.title}" not found for course ${courseMapping.courseCode}. Run assessments migration first.`); } // Look up course_module_id if course_module_code is provided // IMPORTANT: Look up module in the context of the specific course let courseModuleId: number | null = null; if (moduleCode && String(moduleCode).trim()) { // Use module code directly (no longer need to append -ONLINE suffix) // DNP-ONLINE now uses same module codes as DNP (TSN01, TSN02, etc.) const moduleCodeToLookup = String(moduleCode).trim(); // CourseModule is common/shared (not course-based) - find by code only const courseModule = await ( context.prisma as unknown as { courseModule?: { findUnique: (args: { where: { course_module_code: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).courseModule?.findUnique({ where: { course_module_code: moduleCodeToLookup, }, select: { id: true }, }); if (courseModule) { courseModuleId = courseModule.id; } else { // Skip creating question if module not found if (context.dryRun) { console.warn(`⚠️ Skipping question for ${courseMapping.courseCode} - course module with code "${moduleCodeToLookup}" (from "${moduleCode}") not found.`); } else { console.warn( `⚠️ Skipping question (quiz_question_id: ${rowQuiz.quiz_question_id}) for ${courseMapping.courseCode} - course module with code "${moduleCodeToLookup}" (from "${moduleCode}") not found.`, ); } continue; // Skip this course but continue with others } } else { // Skip creating question if no module code provided if (context.dryRun) { console.warn( `─────────────────────────────────────────────────────────────\n` + `⚠️ Skipping question for ${courseMapping.courseCode}: "${questionText}" - no course_module_code found\n` + ` quiz_question_id: ${rowQuiz.quiz_question_id}\n` + ` quiz_title: "${rowQuiz.quiz_title ?? "N/A"}\n`, ); } else { console.warn( `─────────────────────────────────────────────────────────────\n` + `⚠️ Skipping question for ${courseMapping.courseCode}: "${questionText}" - no course_module_code found\n` + ` quiz_question_id: ${rowQuiz.quiz_question_id}\n` + ` quiz_title: "${rowQuiz.quiz_title ?? "N/A"}\n`, ); } continue; // Skip this course but continue with others } payloads.push({ course_id: course.id, assessment_id: assessment.id, course_module_id: courseModuleId, question_text: questionText, sort_order: sortOrder, }); } return payloads.length > 0 ? payloads : null; } // Single course mapping (normal case) const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: mapping.courseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping question - course ${mapping.courseCode} not found`); return null; } throw new Error(`Course with code ${mapping.courseCode} not found. Run course migration first.`); } // Look up assessment by course_id and title const assessment = await ( context.prisma as unknown as { assessment?: { findFirst: (args: { where: { course_id: number; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).assessment?.findFirst({ where: { course_id: course.id, title: mapping.title, }, select: { id: true }, }); if (!assessment) { if (context.dryRun) { console.warn(`⚠️ Skipping question - assessment "${mapping.title}" not found`); return null; } throw new Error(`Assessment "${mapping.title}" not found. Run assessments migration first.`); } // Look up course_module_id if course_module_code is provided (from training_topic table) // IMPORTANT: If course module is not found, we skip creating this question let courseModuleId: number | null = null; if (moduleCode && String(moduleCode).trim()) { // Use module code directly (no longer need to append -ONLINE suffix) // DNP-ONLINE now uses same module codes as DNP (TSN01, TSN02, etc.) const moduleCodeToLookup = String(moduleCode).trim(); // CourseModule is common/shared (not course-based) - find by code only const courseModule = await ( context.prisma as unknown as { courseModule?: { findUnique: (args: { where: { course_module_code: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).courseModule?.findUnique({ where: { course_module_code: moduleCodeToLookup, }, select: { id: true }, }); if (courseModule) { courseModuleId = courseModule.id; } else { // Skip creating question if module not found if (context.dryRun) { console.warn(`⚠️ Skipping question - course module with code "${moduleCodeToLookup}" (from "${moduleCode}") not found.`); } else { console.warn( `⚠️ Skipping question (quiz_question_id: ${rowQuiz.quiz_question_id}) - course module with code "${moduleCodeToLookup}" (from "${moduleCode}") not found.`, ); } return null; } } else { // Skip creating question if no module code provided if (context.dryRun) { console.warn( `─────────────────────────────────────────────────────────────\n` + `⚠️ Skipping question: "${questionText}" - no course_module_code found\n` + ` quiz_question_id: ${rowQuiz.quiz_question_id}\n` + ` quiz_title: "${rowQuiz.quiz_title ?? "N/A"}"\n` + ` training_topic_id: ${rowQuiz.training_topic_id}\n` + ` training_topic_title: ${rowQuiz.training_topic_title ?? "N/A"}\n`, ); } else { console.warn( `─────────────────────────────────────────────────────────────\n` + `⚠️ Skipping question: "${questionText}" - no course_module_code found\n` + ` quiz_question_id: ${rowQuiz.quiz_question_id}\n` + ` quiz_title: "${rowQuiz.quiz_title ?? "N/A"}"\n` + ` training_topic_id: ${rowQuiz.training_topic_id}\n` + ` training_topic_title: ${rowQuiz.training_topic_title ?? "N/A"}\n`, ); } return null; } return { course_id: course.id, assessment_id: assessment.id, course_module_id: courseModuleId, question_text: questionText, sort_order: sortOrder, }; }, upsert: async (payload, { prisma }) => { const prismaClient = prisma as unknown as { assessmentQuestion?: { upsert: (args: { where: { id: number } | { assessment_id: number; sort_order: number }; update: Record<string, unknown>; create: Record<string, unknown>; }) => Promise<unknown>; findFirst: (args: { where: { assessment_id: number; sort_order: number }; }) => Promise<{ id: number } | null>; }; }; if (!prismaClient.assessmentQuestion) { throw new Error( "Prisma client is missing the `assessmentQuestion` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if question already exists by assessment_id + sort_order // This matches questions by their position in the quiz, which is more reliable than question_text const existing = await prismaClient.assessmentQuestion.findFirst({ where: { assessment_id: payload.assessment_id, sort_order: payload.sort_order, }, }); // Create/update question data - course_module_id can be NULL if module not found // This is acceptable - questions and related answers will still be created const questionData = { course_id: payload.course_id, assessment_id: payload.assessment_id, course_module_id: payload.course_module_id, // Can be NULL question_text: payload.question_text, sort_order: payload.sort_order, }; if (existing) { // Update existing question (including setting course_module_id to NULL if needed) await ( prismaClient.assessmentQuestion as unknown as { update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>; } ).update({ where: { id: existing.id }, data: questionData, }); } else { // Create new question await ( prismaClient.assessmentQuestion as unknown as { create: (args: { data: Record<string, unknown> }) => Promise<unknown>; } ).create({ data: questionData, }); } }, }
LEGACY_QUIZ_IDS
Type : unknown
Default value : getLegacyQuizIds()
LEGACY_QUIZ_IDS_SQL_LIST
Type : unknown
Default value : LEGACY_QUIZ_IDS.length > 0 ? LEGACY_QUIZ_IDS.join(", ") : "0"

apps/recallassess/recallassess-api/src/api/shared/constants/assessment-scores.constants.ts

BAT_CHART_COLORS
Type : [string, string, string, string, string]
Default value : [ BAT_COLOR_HEX[SCORE_TO_METRIC[-2].colorClass as keyof typeof BAT_COLOR_HEX], // Foundation BAT_COLOR_HEX[SCORE_TO_METRIC[-1].colorClass as keyof typeof BAT_COLOR_HEX], // Below Average BAT_COLOR_HEX.gray, // Average (no score in SCORE_TO_METRIC) BAT_COLOR_HEX[SCORE_TO_METRIC[1].colorClass as keyof typeof BAT_COLOR_HEX], // Good BAT_COLOR_HEX[SCORE_TO_METRIC[2].colorClass as keyof typeof BAT_COLOR_HEX], // Excellent ]

Pie/polar chart segment order: Foundation, Below Average, Average, Good, Excellent Colors derived from SCORE_TO_METRIC (-2, -1, middle, 1, 2) + BAT_COLOR_HEX

BAT_COLOR_HEX
Type : unknown
Default value : { red: "#d2171c", orange: "#df5e3c", "light-green": "#70ad17", "dark-green": "#1f8308", gray: "#bbb", } as const

Color hex values for BAT report display Used consistently across PRE-BAT and POST-BAT reports

BAT_LEVEL_TO_QUOTIENT
Type : Record<BatAnswerLevel, number>
Default value : { FOUNDATION: 1, INTERMEDIATE: 2, ADVANCED: 3, EXPERT: 4, }

Maps BAT answer levels to numeric values for individual quotient calculation Score range: 1 to 4 Used for calculating average quotient (1-4 scale)

BAT_LEVEL_TO_SCORE
Type : Record<BatAnswerLevel, number>
Default value : { FOUNDATION: -2, INTERMEDIATE: -1, ADVANCED: 1, EXPERT: 2, }

Common constants for mapping BAT answer levels to numeric scores Used across the application for consistent score calculations

SCORE_COLOR_THRESHOLDS
Type : unknown
Default value : { RED: -0.75, // < -0.75 = Red (Foundation) ORANGE_START: -0.75, // >= -0.75, < -0.26 = Orange (Below Average) ORANGE_END: -0.26, LIGHT_GREEN_START: -0.25, // >= -0.25, <= 0.25 = Light Green (Average) LIGHT_GREEN_END: 0.25, GREEN_START: 0.25, // > 0.25, <= 0.75 = Green (Good) GREEN_END: 0.75, DARK_GREEN: 0.75, // > 0.75 = Dark Green (Excellent) } as const

Color classification thresholds based on average score Score range: -2 to +2

SCORE_TO_METRIC
Type : Record<number, literal type>
Default value : { 2: { percentage: 100, colorClass: "dark-green" }, 1: { percentage: 75, colorClass: "light-green" }, [-1]: { percentage: 50, colorClass: "orange" }, [-2]: { percentage: 25, colorClass: "red" }, }

Maps rounded score values to percentage and color class Used for converting average scores to display metrics Score range: -2 to +2 (rounded to nearest integer)

apps/recallassess/recallassess-api/src/config/billing-cycle.ts

BILLING_MONTH_DAYS
Type : number
Default value : 30

Billing uses fixed 30-day "months" (not calendar months).

  • Period (access) days: full length of the subscription term on the calendar (e.g. annual = 12Γ—30 = 360).
  • Pricing multiplier: months actually invoiced (e.g. annual = 10 β†’ two months free vs list monthly).
  • Credit denominator: days basis for unused-value credit (annual = 10Γ—30 = 300, matching what was paid for).

apps/recallassess/recallassess-api/src/templates/email/email-skeleton.template.ts

BRAND_PURPLE
Type : string
Default value : "#7c3aed"
BRAND_PURPLE_DARK
Type : string
Default value : "#6d28d9"
BRAND_TAGLINE_FOOTER
Type : string
Default value : "Development Through Action and Reflection"
BRAND_TAGLINE_HEADER
Type : string
Default value : "Leadership Development Platform"
SUPPORT_EMAIL
Type : string
Default value : "enquiries@recallsolutions.ai"
WEBSITE_LABEL
Type : string
Default value : "www.recallsolutions.ai"
WEBSITE_URL
Type : string
Default value : "https://recallsolutions.ai"

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/valuelist.migration.ts

cachedUserId
Type : number | null
Default value : null
STATIC_VALUELIST_ENTRIES
Type : Omit[]
Default value : [ // Course Categories { valuelist_name: "Course - Category", value: "Communication", code: null }, { valuelist_name: "Course - Category", value: "Negotiation", code: null }, { valuelist_name: "Course - Category", value: "Sales", code: null }, { valuelist_name: "Course - Category", value: "Leadership", code: null }, { valuelist_name: "Course - Category", value: "Management", code: null }, { valuelist_name: "Course - Category", value: "Persuasion", code: null }, // Course Levels { valuelist_name: "Course - Level", value: "Foundation", code: null }, { valuelist_name: "Course - Level", value: "Intermediate", code: null }, { valuelist_name: "Course - Level", value: "Advanced", code: null }, { valuelist_name: "Course - Level", value: "Expert", code: null }, // Add more static values as needed ]

Static Valuelist Migration

This migration seeds static value list entries for the system. Common values include course categories, course levels, and other reference data.

valuelistMigration
Type : MigrationDefinition<Omit<ValuelistPayload, user_id_created_by>, ValuelistPayload>
Default value : { name: "valuelist", // Static data seeding - doesn't query legacy MySQL tables // Uses __LOAD_FROM_JSON__ pattern to skip MySQL query sourceQuery: "__LOAD_FROM_STATIC__", transform: async (row, context): Promise<ValuelistPayload | null> => { const entry = row as Omit<ValuelistPayload, "user_id_created_by">; const userId = await getSystemUserId(context); return { ...entry, user_id_created_by: userId, }; }, upsert: async (payload, { prisma }) => { // Check if entry already exists const existing = await prisma.valuelist.findFirst({ where: { valuelist_name: payload.valuelist_name, value: payload.value, }, }); if (existing) { // Update existing entry (no changes needed, just ensure it exists) // No update needed since we only have static values } else { // Create new entry await prisma.valuelist.create({ data: { valuelist_name: payload.valuelist_name, value: payload.value, code: null, user_id_created_by: payload.user_id_created_by, }, }); } }, }

apps/recallassess/recallassess-api/src/assets/certificates.ts

certificates
Type : []
Default value : [ "01204.jpg", "02494 ICA.jpg", "00867.jpg", "02916.jpg", "04142.jpg", "02733_2.jpg", "01012.jpg", "04167_2.jpg", "04195.jpg", "05000_19 - 4.24cts.jpg", "00522.jpg", "04022.jpg", "02309.jpg", "02484.jpg", "01015-BLUE SAPP 3.32.jpg", "01833.jpg", "04515_1.jpg", "03040_1.jpg", "01015-BLUE SAPP 4.07.jpg", "02679.jpg", "01642_1.jpg", "01015-GREENISH BLUE SAPP 7.32.jpg", "04047_2.jpg", "02876.jpg", "00969_1.jpg", "02862.jpg", "04531_1.jpg", "00906.jpg", "00084.jpg", "04209.jpg", "02678.jpg", "04158_1.jpg", "04045_1.jpg", "01832.jpg", "02308.jpg", "04281-1.jpg", "01167.jpg", "02320.jpg", "04023.jpg", "02921_2.jpg", "01775.jpg", "02263_2.jpg", "04143.jpg", "02254.jpg", "02917.jpg", "02903.jpg", "00965_1 MISSING.jpg", "04165_1.jpg", "02731_1.jpg", "02042.jpg", "00131.jpg", "02844 APPENDIX.jpg", "02562-APPENDIX.jpg", "02915.jpg", "02901.jpg", "04141.jpg", "02843 APPENDIX.jpg", "04167_1.jpg", "04021.jpg", "04009.jpg", "01165.jpg", "04171_1 - 6.16cts.jpg", "03040_2.jpg", "02576 GRS MISSING.jpg", "04223.jpg", "04047_1.jpg", "00092.jpg", "01642_2.jpg", "02849.jpg", "00910.jpg", "00573 GRS.jpg", "02875.jpg", "03042_1.jpg", "02874.jpg", "02860.jpg", "00093.jpg", "04593.jpg", "02856 APPENDIX.jpg", "00078.JPG", "04158_2.jpg", "01015-BLUE SAPP 4.05.jpg", "04026_1.jpg", "01015-BLUE SAPP 3.30.jpg", "01602.jpg", "04008.jpg", "02921_1.jpg", "04197.jpg", "02263_1.jpg", "01776.jpg", "01051-GRS.jpg", "02900.jpg", "04165_2.jpg", "02289 - OVAL 1.16 CTS.jpg", "05000_9 - 4.59cts.jpg", "02041.jpg", "02727.jpg", "02731_2.jpg", "01033_3.jpg", "01558.jpg", "04286_1.jpg", "04144.jpg", "02253.jpg", "04132-AIGS.jpg", "03011.jpg", "04018.jpg", "02333.jpg", "04024.jpg", "02455.jpg", "00049_1.jpg", "02482.jpg", "00154_1.jpg", "00068.jpg", "02289 - OVAL 1.16 CTS_0001.jpg", "04226.jpg", "00915.jpg", "02870.jpg", "01015-PINK SAPP 2.83.jpg", "04597.jpg", "02680.jpg", "04596.jpg", "02681.jpg", "04163_1.jpg", "02859.jpg", "02871.jpg", "04227.jpg", "00733.jpg", "02642.jpg", "02454.jpg", "04025.jpg", "02289 - OVAL 1.65 CTS.jpg", "04019.jpg", "02905.jpg", "00874.jpg", "02911.jpg", "02794_2.jpg", "01033_2.jpg", "00889.jpg", "00123.jpg", "04054_16 - 2.03cts.jpg", "00862.jpg", "04286_2.jpg", "04218_1.jpg", "04171_4 - 6.03cts.jpg", "03012.jpg", "01015-YELLOW SAPP 3.96.jpg", "02456.jpg", "04999.jpg", "01015-GREENISH BLUE SAPP 3.84.jpg", "00049_2.jpg", "02481.jpg", "04219.jpg", "00154_2.jpg", "04225.jpg", "02898.jpg", "04557.jpg", "00902.jpg", "02873.jpg", "04054_9 - 1.23cts.jpg", "02683.jpg", "04594.jpg", "04163_2.jpg", "04224.jpg", "02669.jpg", "02480.jpg", "01015-YELLOW SAPP 3.83.jpg", "03013.jpg", "01758.jpg", "02721.jpg", "01033_1.jpg", "02794_1.jpg", "02752.jpg", "05015.jpg", "05001.jpg", "04046_1.jpg", "04054_4 - 2.10cts.jpg", "00384.jpg", "03041_2.jpg", "04041.jpg", "00569.jpg", "01015-GREENISH BLUE SAPP 3.35.jpg", "04166_1.jpg", "04096.jpg", "02632.jpg", "00964.jpg", "02801.jpg", "04164_2.jpg", "02885_1.jpg", "04281.jpg", "04243_1.jpg", "02289 - OVAL 1.05 CTS.jpg", "01015-BLUE SAPP 3.50.jpg", "04097.jpg", "02201_2.jpg", "01562.jpg", "01166.jpg", "02645.jpg", "02644.jpg", "02554-APPENDIX.jpg", "01561.jpg", "02646.jpg", "02109.jpg", "00497.jpg", "01969.jpg", "02735_2.jpg", "01045_2(RECUT).jpg", "02643.jpg", "02722.jpg", "02735_1.jpg", "02640.jpg", "01016.jpg", "02419-APPENDIX.jpg", "02732_1.jpg", "01677.jpg", "02419.jpg", "00420.jpg", "00837 recut to 4.96.jpg", "00057_2.jpg", "00095_2.jpg", "02732_2.jpg", "01112.jpg", "02635.jpg", "01704.jpg", "01276.jpg", "02559-APPENDIX.jpg", "00965_2 MISSING.jpg", "02420.jpg", "02554.jpg", "00800.jpg", "02559.jpg", "01454.jpg", "01496.jpg", "02740_2.jpg", "01286.jpg", "02957.jpg", "02638.jpg", "01324.jpg", "02740_1.jpg", "00966_2.jpg", "00057_2-APPENDIX.jpg", "02170.jpg", "01068.jpg", "01054.jpg", "01219.jpg", "01594.jpg", "01190.jpg", "02420-APPENDIX.jpg", "02117.jpg", "01595.jpg", "00886.jpg", "00045_1.jpg", "01237.jpg", "00779 old ( make recut).jpg", "01305_1.jpg", "00145_1.jpg", "02649.jpg", "00145_2.jpg", "00385.jpg", "04171_2 - 6.63cts.jpg", "02065_2.jpg", "02784.jpg", "02576 ICA MISSING.jpg", "05002.jpg", "05016.jpg", "02792.jpg", "02786.jpg", "02289 - OVAL 1.41 CTS.jpg", "04046_2.jpg", "03041_1.jpg", "04222_2.jpg", "04042.jpg", "03039_1.jpg", "04095.jpg", "04166_2.jpg", "00032.jpg", "02631.jpg", "00998.jpg", "04268.jpg", "02802.jpg", "02408_1 GRS.jpg", "04171_10 - 4.99cts.jpg", "02803.jpg", "04269.jpg", "02630.jpg", "00027.jpg", "02142.jpg", "01015-YELLOW SAPP 3.19.jpg", "02148 MISSING.jpg", "01015-YELLOW SAPP 3.31.jpg", "04094.jpg", "02482-APPENDIX.jpg", "02201_1.jpg", "04043.jpg", "04057.jpg", "00392.jpg", "02546.jpg", "02065_1.jpg", "02787.jpg", "02793.jpg", "05017.jpg", "05003.jpg", "05007.jpg", "05013.jpg", "02754.jpg", "00802.jpg", "02218.jpg", "04202_2.jpg", "05000_20 - 3.63cts.jpg", "04160_1.jpg", "02289 - OVAL 1.50 CTS.jpg", "02289 - OVAL 1.22 CTS.jpg", "02289 - OVAL 1.32 CTS.jpg", "04053.jpg", "00590.jpg", "04090.jpg", "01235 - Appendix.jpg", "00584.jpg", "00779.jpg", "02634.jpg", "02482-AIGS.jpg", "02849 APPENDIX.jpg", "04170-4.35cts.jpg", "04054_18.jpg", "01116.jpg", "02219.jpg", "02065_1 Appendix.jpg", "04162_2.jpg", "00803.jpg", "04054_5 - 1.45cts.jpg", "02858_2.jpg", "05006.jpg", "05010.jpg", "01015-WHITE SAPP 4.64.jpg", "05004.jpg", "02757.jpg", "01074.jpg", "02555.jpg", "04202_1.jpg", "04160_2.jpg", "03071.jpg", "02408_2.jpg", "03047_1.jpg", "04534_2.jpg", "01015-YELLOW SAPP 3.36.jpg", "04093.jpg", "04291.jpg", "04284.jpg", "04054_14 - 1.66cts.jpg", "02289 - PEAR 1.48 CTS.jpg", "04092.jpg", "02352.jpg", "03070.jpg", "02795.jpg", "01288.jpg", "04162_1.jpg", "02494 BELL.jpg", "02736_1.jpg", "02756.jpg", "05005.jpg", "02858_1.jpg", "05011.jpg", "05008.jpg", "02773.jpg", "05020.jpg", "00819.jpg", "04598 (AIGS).jpg", "02289 - OVAL 1.01 CTS.jpg", "04100.jpg", "04128.jpg", "04054_20 - 1.96cts.jpg", "02613.jpg", "02161.jpg", "01440.jpg", "00986.jpg", "01326.jpg", "02404_2.jpg", "05000_28 - 3.96cts.jpg", "04151_2.jpg", "01864.jpg", "00593_2.jpg", "04075.jpg", "01079.jpg", "02570.jpg", "02955.jpg", "02738_2.jpg", "02799.jpg", "03050_2.jpg", "00818.jpg", "04290_2.jpg", "05009.jpg", "01015-YELLOW SAPP 2.86.jpg", "02758.jpg", "00629.jpg", "00403.jpg", "04148_1.jpg", "04077.jpg", "02406.jpg", "02289 - ROUND 0.63 CTS.jpg", "02289 - OVAL 0.98 CTS.jpg", "01015-YELLOW SAPP 3.05.jpg", "01866.jpg", "04088.jpg", "02289 - OVAL 1.08 CTS.jpg", "00946.jpg", "04151_1.jpg", "02404_1.jpg", "01388 MISSING.jpg", "02407.jpg", "04076.jpg", "00593_1.jpg", "02573.jpg", "00402.jpg", "00126 GRS.jpg", "03050_1.jpg", "02765.jpg", "01044_2.jpg", "04290_1.jpg", "05026.jpg", "00126 GUILD.jpg", "02007.jpg", "00837.jpg", "04054_2 - 1.40cts.jpg", "01042.jpg", "04168_1.jpg", "03053.jpg", "02779_1.jpg", "04099.jpg", "01015-YELLOW SAPP 4.21.jpg", "01015-YELLOW SAPP 4.09.jpg", "01334.jpg", "3.25 SAPPHIRE CUSH - GRS.jpg", "3.84 - Sapp Cus - GRS.jpg", "3.38 - SAPPHIRE OVAL - GRS.jpg", "9.75 EMERALD OCT MINOR - GRS.jpg", "1.24 - Sapphire Cushion - GRS.jpg", "2.491 - Sapp Oct - IGL.jpg", "18.97 SAPPHIRE CUSH - GRS.jpg", "6.70 - Green Yellow Sapp Cus - GRS.jpg", "2.99 - Sapp Oval - IGL.jpg", "6.12 - Green Yellow Sapp Cus - GRS.jpg", "3.10 RUBY OVAL - SSEF.jpg", "5.87 EMERALD OCT NO OIL - GRS.jpg", "2.672 - Sapp RD - IGL.jpg", "3.16 - SAPPHIRE OVAL - GRS.jpg", "6.16 SAPPHIRE OVAL - GRS.jpg", "2.307 - Sapp Cus - IGL.jpg", "2.08 - Eme Pear - GRS.jpg", "4.49 - Sapp Oval - GIA.jpg", "4.88 - Sapp RD - GRS.jpg", "5.24 EMERALD OCT INSIG - GRS.jpg", "2.68 EMERALD OCT NO OIL - GRS.jpg", "16.02 - Sapp Oval - GRS.jpg", "2.03 - Ruby Oval - GRS.jpg", "12.77 - Emerald Oct - GRS.JPG", "4.27 - Sapp Cus - Lotus.jpg", "4.96 - Sapp Oval - GRS.jpg", "23.03 - Eme Oct - GRS.jpg", "1.84 SAPPHIRE OVAL - GUILD.jpg", "4.64 SAPPHIRE OCT - GRS.jpg", "2.89 - Sapp Cus - GRS.jpg", "4.19 - Greenish Yellow Sapp Cushion - GRS.jpg", "4.87 SAPPHIRE OVAL - GRS.jpg", "6.82 - Sapp RD - GRS.jpg", "4.76 - Sapp Cus - GRS.jpg", "4.24 - Emerald Oval - GRS.JPG", "10.84 - Sapp Oval - AGL.jpg", "5.49 - Sapp Cus - GRS.jpg", "9.00 - Sapp Cushion - GRS.jpg", "8.68 - Sapp Cush - GRS.jpg", "2.02 RUBY CUSH.jpg", "2.895 - Sapp RD - IGL.jpg", "4.56 - SAPPHIRE OVAL - GRS.jpg", "4.00 - SAPPHIRE CUSH - GRS.jpg", "6.02 - Sapp Cushion - GIA.jpg", "9.56 - Sapp Oval - GRS.jpg", "6.85 - Sapp Oval - GRS.jpg", "6.07 - Eme Oct - GRS.jpg", "9.13 - Sapp Oval - GIA.jpg", "3.48 SAPPHIRE CUSH - GRS.jpg", "14.51 - Emerald Pear - TGL.jpg", "14.16 - Eme Oct - GRS.jpg", "Sapphire - 2.20 cts.jpg", "4.49 - Colorless Sapp Cus - GRS.jpg", "10.55 EMERALD OCT INSIG - GRS.jpg", "9.32 - Emerald Oct - GIA.JPG", "2.83 - Pink Sapp Cus - GRS.jpg", "2.34 - Pink Sapp Cus - GRS.jpg", "3.45 - Sapp oval - GRS.jpg", "6.58 - Emerald Oct - GRS.jpg", "4.10 - Ruby Oval - TGL.jpg", "7.12 - Eme Oct - GRS.jpg", "2.07 - Eme Pear - GRS.jpg", "3.05 - Ruby Oval - TGL.jpg", "3.05 - Ruby Oval - GRS.jpg", "3.78 - Sapp Cus - GRS.jpg", "2.58 SAPPHIRE CUSH - GRS.jpg", "3.46 - Sapphire Cushion - GRS.jpg", "3.22 - SAPPHIRE CUSH - GRS.jpg", "12.07 - Sapp Heart - GRS.jpg", "5.26 - Sapp Oval - GRS.jpg", "4.11 - Yellow Sapp Cus - GRS.jpg", "2.612 - Sapp Oval - IGL.jpg", "6.45 - SAPPHIRE CUSH - GRS.jpg", "3.25 - Eme Pear - GRS.jpg", "5.30 - Eme Round - GRS.jpg", "11.49 - Eme Oct - GRS.jpg", "3.59 - Eme Oval - GRS.jpg", "3.59 - Sapp Oval - GRS.jpg", "3.07 SAPPHIRE OVAL - GRS.jpg", "6.00 - Yellow Sapp Oval - GRS.jpg", "3.36 SAPPHIRE PEAR - GRS.jpg", "5.45 - SAPPHIRE OVAL - GRS.jpg", "11.04 - Sapp Oct - GRS.jpg", "4.12 - Sapp Cus - GRS.jpg", "4.18 - Sapphire Cushion - GRS.jpg", "4.53 - Sapp Heart - GRS.jpg", "4.51 - SAPPHIRE OVAL - GRS.jpg", "3.47 SAPPHIRE OVAL - GRS.jpg", "Sapphire - 2.21 cts.jpg", "3.63 - Sapp Oval - GRS.jpg", "3.75 - Sapp Oval - GRS.jpg", "3.95 - SAPPHIRE OVAL - GRS.jpg", "3.16 - Sapp Cus - GRS.jpg", "3.90 EMERALD PEAR INSIG - GRS.jpg", "5.11 - Eme Oval - GRS.jpg", "3.55 - SAPPHIRE OVAL - GRS.jpg", "3.96 - Yellow Sapp Oval - GRS.jpg", "4.14 SAPPHIRE CUSH - GRS.jpg", "4.12 - SAPPHIRE CUSH - GRS.jpg", "10.19 SAPPHIRE CUSH - GRS.jpg", "4.07 - Sapp Cushion - GRS.jpg", "Sapphire - 1.69 cts.jpg", "3.38 - SAPPHIRE CUSH - GRS.jpg", "13.52 - Sapp Oval - GRS.jpg", "7.82 SAPPHIRE OVAL - GRS.jpg", "8.43 SAPPHIRE PEAR - GRS.jpg", "2.98 SAPPHIRE OVAL - GRS.jpg", "2.88 EMERALD OCT INSIG - GRS.jpg", "6.68 - White Sapp Cus - GRS.jpg", "3.30 - Sapphire Cushion - GRS (Blue).jpg", "3.31 - Yellow Sapp Oval - GRS.jpg", "6.41 SAPPHIRE OVAL - GRS.jpg", "5.08 - Sapp Oval - GRS.jpg", "3.66 SAPPHIRE ROUND - GRS.jpg", "3.130 - Sapp Cus - IGL.jpg", "4.51 - Emerald Oct - GRS.jpg", "2.84 SAPPHIRE OVAL - GUILD.jpg", "3.40 EMERALD OCT MINOR - GRS.jpg", "2.04 RUBY PEAR - GUILD.jpg", "2.031 - Sapp RD - IGL.jpg", "2.74 - Pink Sapp Cushion - GRS.jpg", "14.77 - SAPPHIRE CUSH - GRS.jpg", "7.30 EMERALD OCT - GRS.jpg", "1.66 - Emerald Oval - GRS.jpg", "7.30 - Eme Oct - GIA.jpg", "12.43 - Sapp OCt - GIA.jpg", "6.15 - Sapp Cus - GRS.jpg", "3.22 - Sapp RD - GRS.jpg", "4.05 - Sapphire Cushion - GRS.jpg", "2.07 - Ruby Pear - IGI.jpg", "5.70 - Sapp Cus - GRS.jpg", "4.45 - Emerald Oval - GIA.jpg", "3.59 - Eme MQ - GRS.jpg", "2.35 - RUBY OVAL - GRS.jpg", "3.35 - SAPPHIRE CUSH - GRS.jpg", "3.83 EMERALD PEAR INSIG - GRS.jpg", "3.01 - Ruby Oval - GRS (1).jpg", "2.648 - Sapp Oval - IGL.jpg", "3.03 - Sapp Oval - GRS.jpg", "5.61 - Sapp Oct - Lotus.jpg", "5.71 - Sapp Oval - GIA.jpg", "3.64 - SAPPHIRE CUSH - GRS.jpg", "3.41 - Sapp Oval - GRS.jpg", "7.20 - Sapp Cus - GRS.jpg", "6.25 - SAPPHIRE OVAL - GRS.jpg", "3.98 - Yellow Sapp Cushion - GRS (2).jpg", "7.77 - Yellow Sapp Cus - GRS.jpg", "4.32 - Eme Pear - GRS.jpg", "3.01 - Sapp Oval - GRS (1).jpg", "3.36 EMERALD OCT NO OIL - GRS.jpg", "17.24 SAPPHIRE OVAL - GRS.jpg", "3.02 RUBY OVAL.jpg", "4.23 - Emerald Oct - GRS.jpg", "4.14 - Sapp Oval - Lotus.jpg", "3.01 - Ruby Oval - GRS.jpg", "12.58 - Sapp Cushion - GRS.jpg", "9.20 - EMERALD OVAL CABOCHON - GRS.jpg", "19.22 SAPPHIRE CUSH - GRS.jpg", "3.13 SAPPHIRE CUSH - GRS.jpg", "3.30 - Yellow Sapp Cushion - GRS (Yellow).jpg", "3.76 - SAPPHIRE PEAR - GRS.jpg", "7.98 - Emerald Oval - GRS.jpg", "4.63 - Sapphire Cushion - GRS.jpg", "4.66 - Sapp Oval - GRS.jpg", "4.70 - Sapp Oval - TGL.jpg", "4.66 - PINK SAPP CUSH - GRS.jpg", "3.32 - Sapp Cus - GRS.jpg", "2.30 EMERALD OCT - GRS.jpg", "3.04 - Pink Sapp Cus - GRS.jpg", "12.53 SAPPHIRE OVAL - GRS.jpg", "6.60 SAPPHIRE OVAL - GRS.jpg", "2.11 - Ruby Oval - GRS.jpg", "4.36 - Sapp Cus - GRS.jpg", "5.88 SAPPHIRE HEART - GRS.jpg", "3.99 - Sapp Heart- GRS.jpg", "8.27 - Sapp Oval -GRS.jpg", "2.466 - Sapp Oct - IGL.jpg", "2.18 - Pink Sapp Cus - GRS.jpg", "4.56 EMERALD OCT INSIG - GRS.jpg", "5.83 - Sapp Cushion - GRS.jpg", "5.08 - Sapp Cus - Lotus.jpg", "7.16 - Sapp Oct - GRS.jpg", "2.53 - EMERALD PEAR - GRS.jpg", "1.45 EMERALD PEAR INSIG - GRS.jpg", "4.10 - Sapp Oval - Lotus.jpg", "6.20 - Fancy Sapp Cus - GRS.jpg", "2.69 EMERALD OCT INSIG - GRS.jpg", "2.148 - Sapp Oct - IGL.jpg", "3.55 - Sapp Oval - GRS.jpg", "4.98 - Sapp Oval - GRS.jpg", "2.21 - Ruby Oval - GRS.jpg", "6.35 SAPPHIRE HEART - GRS.jpg", "4.53 - Eme Oval - GRS.jpg", "3.03 EMERALD OCT INSIG - GRS.jpg", "12.22 SAPPHIRE CUSH - GRS.jpg", "3.57 - SAPPHIRE OVAL - GRS.jpg", "15.46 - Eme Oct - GRS.jpg", "1.475 - Sapp RD - IGL.jpg", "3.01 - Sapp Oval - GRS.jpg", "5.87 - Sapp Oval - GIA.jpg", "7.96 SAPPHIRE OVAL - GRS.jpg", "3.448 - Sapp Oval - IGL.jpg", "4.48 - Sapp Oval - GRS.jpg", "4.85 - Sapphire Cushion - GRS.jpg", "14.47 SAPPHIRE CUSH - GRS.jpg", "3.58 - Eme Oval - GRS.jpg", "7.18 EMERALD OCT MINOR - GRS.jpg", "16.78 - Sapp Oct - GRS.jpg", "12.65 - SAPPHIRE CUSH - GRS.jpg", "3.21 - EME OVAL - GRS.jpg", "4.08 - Sapphire Cushion - GRS.jpg", "3.93 - Sapp Oval - GRS.jpg", "9.13 - Sapp Heart - GRS.jpg", "4.99 - Emerald Oct - GRS.JPG", "6.58 - Eme Oct - GRS.jpg", "1.38 EMERALD PEAR INSIG - GRS.jpg", "14.76 SAPPHIRE CUSH - GRS.jpg", "4.00 RUBY OVAL.jpg", "1.84 - SAPPHIRE BOVAL - EGL.jpg", "2.38 EMERALD OCT MINOR - GRS.jpg", "6.15 - Emerald Oval - GRS.jpg", "2.58 EMERALD PEAR MINOR - GRS.jpg", "1.84 EMERALD PEAR INSIG - GRS.jpg", "7.94 - Tourmaline Oval - AIGS.jpg", "5.87 - Sapp Oct - GRS.jpg", "2.89 - Ruby Oval - GRS.jpg", "4.09 - Yellow Sapp Cus - GRS.jpg", "1.51 - Oval Ruby - TGL.jpg", "2.46 SAPPHIRE OVAL - GUILD.jpg", "2.11 - Eme Pear - GRS.jpg", "9.10 SAPPHIRE CUSH - GRS.jpg", "5.04 - Sapp Oval - GRS.jpg", "6.12 - Sapp pear GRS.jpg", "4.99 - Sapp Cus - GRS.jpg", "11.94 - Eme OCt _ GRS.jpg", "13.50 SAPPHIRE CUSH - GRS.jpg", "2.16 - Ruby Cushion - GRS.jpg", "9.57 - Sapp Oval - GRS.jpg", "2.574 - Sapp RD - IGL.jpg", "3.84 - SAPPHIRE CUSH - GRS.jpg", "8.04 - Emerald Oval - GRS.jpg", "1.81 - Yellow Sapp Cushion - GRS.jpg", "4.99 EMERALD OCT NO OIL - GRS.jpg", "4.62 - Eme Oval - GRS.jpg", "3.85 - SAPPHIRE OCTAGON - GRS.jpg", "4.05 - Sapp Oval - GRS.jpg", "4.69 - Sapp Cus - GRS.jpg", "3.83 - Purple Sapp Cushion - GRS (Purple).jpg", "4.54 - Sapp Cus - GRS.jpg", "6.14 - Eme Round - GRS.jpg", "34.31 SAPPHIRE CUSH - GRS.jpg", "5.01- RUBY OVAL - GRS.jpg", "8.63 - Sapp Oval - GRS.jpg", "6.99 - Emerald Oval - GIA.jpg", "9.52 SAPPHIRE OVAL - GRS.jpg", "3.29 - Sapp Cus - GRS.jpg", "3.50 - Sapp Cus - GRS.jpg", "4.68 - Yellow Sapp Oval - GRS.jpg", "6.40 - Sapp Cus - GRS.jpg", "3.86 SAPPHIRE OVAL - GRS.jpg", "5.17 - Sapp Cush - GRS.jpg", "2.03 RUBY OVAL-1.jpg", "2.50 EMERALD OCT INSIG - GRS.jpg", "4.30 EMERALD OCT NO OIL - GRS.jpg", "4.63 - Sapp Oval - GRS.jpg", "7.97 - Orange Pink Sapp -GRS.jpg", "4.12 EMERALD OCT INSIG - GRS.jpg", "15.11 SAPPHIRE CUSH - GRS.jpg", "4.55 EMERALD OCT INSIG - GRS.jpg", "3.12 - RUBY OVAL - GIL.jpg", "4.21 - Yellow Sapp Cushion - GRS.jpg", "2.61 - Eme Pear - GRS.jpg", "4.10 - Sapp Cushion - GIA.jpg", "4.75 EMERALD OVAL INSIG - GRS.jpg", "5.03 - Sapp Oval - GRS.jpg", "1.85 EMERALD PEAR INSIG - GRS.jpg", "9.32 - Eme Oct - GRS.jpg", "2.03 RUBY OVAL -2.jpg", "11.57 - Eme Oct -GRS.jpg", "33.17 - Sapp Oval - GRS.jpg", "8.07 EMERALD OVAL MINOR - GRS.jpg", "6.49 EMERALD OCT NO OIL - GRS.jpg", "2.55 EMERALD OCT INSIG - GRS.jpg", "4.81 - Sapp Oval - GRS.jpg", "4.07 - Sapphire Cushion - GRS.jpg", "3.02 EMERALD PEAR INSIG - GRS.jpg", "2.16 EMERALD OVAL - GRS.jpg", "3.04 RUBY OVAL.jpg", "Sapphire - 2.11 cts.jpg", "4.66 - Sapp Cus - GRS.jpg", "1.050 - RUBY CUSH - EGL.jpg", "12.11 SAPPHIRE OVAL - GRS.jpg", "10.04 SAPPHIRE CUSH - GRS.jpg", "2.932 - Sapp RD - IGL.jpg", "2.130 - Sapp RD - IGL.jpg", "3.35 - Green Blue Cus - GRS.jpg", "2.85 - Eme Pear - GRS.jpg", "4.48 - Yellow Sapp Cushion - GRS.jpg", "3.99 - Sapp Oval - Lotus.jpg", "7.32 - Green Blue Sapp Cus - GRS.jpg", "3.91 - SAPPHIRE PEAR - GRS.jpg", "2.71 - SAPPHIRE ROUND - GRS.jpg", "9.43 - Yellow Sapphire Oval - GIA.jpg", "3.37 EMERALD OCT NO OIL - GRS.jpg", "2.11 - Pink Sapp Cushion - GRS.jpg", "4.11 - Sapp Oval - GRS.jpg", "10.62 - SAPPHIRE PEAR - GRS.jpg", "8.67 - SAPPHIRE CUSH - GRS.jpg", "5.41 - Sapp Cus - GRS.jpg", "6.99 EMERALD OVAL MINOR - GRS.jpg", "4.87 - Emerald Oct - GRS.jpg", "6.24 - Sapp Cus - GRS.jpg", "2.659 - Sapp Oval - IGL.jpg", "3.03 - Emerald Oct - GRS.JPG", "3.19 - Yellow Sapp Cushion - GRS.jpg", "3.08 - SAPPHIRE OVAL - GRS.jpg", "5.00 - Sapp Cus - GRS.jpg", "2.352 - Sapp Oct - IGL.jpg", "4.07 - Sapp Oval - GRS.jpg", "Sapphire - 1.58 cts.jpg", "5.96 SAPPHIRE OCT - GRS.jpg", "3.03 RUBY CUSH.jpg", "1.24 - Emerald Rhomboid - GIA.jpg", "7.15 EMERALD OCT - GRS.jpg", "7.40 EMERALD OCT INSIG - GRS.jpg", "3.01 - Ruby Oval - GRS (2).jpg", "3.23 EMERALD OCT INSIG - GRS.jpg", "3.02 SAPPHIRE OVAL - GRS.jpg", "3.05 - Sapp Cushion - GIA.jpg", "2.321 - Sapp oct - IGl.jpg", "3.71 - Eme pear - GRS.jpg", "7.88 - Eme Oct - GRS.jpg", "3.98 - Yellow Sapp Cushion - GRS (1).jpg", "Sapphire - 1.67 cts.jpg", "5.04 - Pink Orange Sapp Cus - GRS.jpg", "6.98 - Sapp Oval - GRS.jpg", "3.83 - Yellow Sapp Cushion - GRS.jpg", "3.05 RUBY PEAR.jpg", "3.26 - Sapp Oval - GRS (1).jpg", "3.10 RUBY RING OVAL - GRS.jpg", "2.92 - Sapp Oct - GRS.jpg", "3.12 - Ruby Cush - GRS.jpg", "21.45 SAPPHIRE CUSH - GRS.jpg", "9.52 - Tourmaline Round - AIGS.jpg", "7.71 EMERALD OCT NO OIL - GRS.jpg", "3.94 - Greenish Blue Sapp Cushion - GRS.jpg", "3.14 - Eme Oval - GRS.jpg", "9.23 - Sapp Oval - GRS.jpg", "4.73 - Sapp Oval - Lotus.jpg", "0.99 - Pink Sapp Cushion - GRS.jpg", "37.20 SAPPHIRE OVAL - GRS.jpg", "2.04 - Ruby Oval - IGL.jpg", "3.64 - Sapp Oval - GRS.jpg", "2.06 - Pink Sapp Cushion - GRS.jpg", "3.33 - Pink Sapp Cushion - GIT.jpg", "6.78 - Eme Oval - GRS.jpg", "3.60 - Sapphire Cushion - GRS.jpg", "8.79 - Sapp Oval - GRS.jpg", "3.18 - SAPPHIRE OVAL - GRS.jpg", "3.26 - Sapp Oval - GRS.jpg", "3.48 - Sapp Oval - GRS.jpg", "5.00 - SAPPHIRE OVAL - GRS.jpg", "7.68 - Emerald Oct - GRS.jpg", "1.11 - Pink Sapp Cushion - GRS.jpg", "20.28 SAPPHIRE CUSH - GRS.jpg", "4.02 Sapp Oval - GRS.jpg", "15.12 - Sapp Pear - GRS.jpg", "3.41 - SAPPHIRE OVAL - GRS.jpg", "15.11 - Emerald Oval - GRS.jpg", "3.51 EMERALD OCT - GRS.jpg", "3.42 EMERALD OVAL - GRS.jpg", "5.05+4.68=9.73 - SAPPHIRE PEAR - GRS.jpg", "3.54 - Sapp Cus - GRS.jpg", "4.66 - Emerald Oct - GRS.jpg", "3.907 - Sapp Oval -IGL.jpg", "4.07 - Sapp Oct - GRS.jpg", "4.80 - Eme Oct - GRS.jpg", "8.07 - Emerald Oval - GIA.jpg", "2.164 - Ruby Cushion - IGL.jpg", "16.69 Emerald Oct - GIA.JPG", "6.07 SAPPHIRE OVAL - GRS.jpg", "20.12 SAPPHIRE OVAL - GRS.jpg", "4.06 - Sapp Oval - GIA.jpg", "4.26 - EMERALD OCT - GRS.jpg", "3.12 SAPPHIRE OVAL - GRS.jpg", "2.13 - EMERALD CUSH - GRS.jpg", "4.84 - Emerald Oct - GRS.jpg", "3.05 - SAPPHIRE OVAL - GRS.jpg", "2.39 EMERALD OCT - GRS.jpg", "8.25 - Sapp Pear - GRS.jpg", "2.04 RUBY OVAL -GRS.jpg", "8.59 SAPPHIRE HEART - GRS.jpg", "2.41 EMERALD OCT - GRS.jpg", "3.06 EMERALD OVAL - GRS.jpg", "9.15 - Sapp Sugat loaf - GIA.jpg", "2.17 RUBY OVAL.jpg", "3.45 EMERALD OVAL INSIG - GRS.jpg", "3.57 EMERALD OCT NO OIL - GRS.jpg", "3.84 - Greenish Blue Sapp Cushion - GRS.jpg", "3.09 - Sapp Oval - Lotus.jpg", "3.10 - Sapp Cus - GRS.jpg", "4.55 - Sapp Cus - GRS.jpg", "3.79 EMERALD OVAL INSIG - GRS.jpg", "2.943 - Sapp Oval - IGL.jpg", "5.19 - Brownish Pink Sapp Cushion - GRS.jpg", "4.06 - Sapp Cus - GRS.jpg", "4.31 - SAPP OVAL - GRS.jpg", "3.92 - Sapp Oval - GRS.jpg", "12.81 SAPPHIRE OVAL - GRS.jpg", "3.32 - Sapp Oval - GRS.jpg", "4.91 - Sapp Oval - GRS.jpg", "9.85 - Eme Oct - GRS.jpg", "4.28 - SAPPHIRE OVAL - GRS.jpg", "5.28 - Eme Oct - GRS.jpg", "14.55 SAPPHIRE OVAL - GRS.jpg", "4.45 SAPPHIRE CUSH - GRS.jpg", "12.33 - Emerald Pear - TGL.jpg", "2.07 - RUBY PEAR - IGI (1).jpg", "2.75 SAPPHIRE CUSH - GUILD.jpg", "2.86 - Yellow Sapp Cushion - GRS.jpg", "1.235 - Tourmaline - Pear - EGL (Japan)_1.jpg", "7.20 - SAPPHIRE CUSH - GRS.jpg", "7.88 - Eme Oct - GRS (1).jpg", "6.08 SAPPHIRE CUSH - GRS.jpg", "1.08 - Pink Sapp Cushion - GRS.jpg", "8.09 - Sapp Cush - GRS.jpg", "12.77 - Emerald Oct - GIA.jpg", "3.08 - Sapp Oval - GRS.jpg", "7.27 - Sapp Cus - GRS.jpg", "10.45 EMERALD OCT - GRS.jpg", "5.85 EMERALD OCT INSIG - GRS.jpg", "15.94 - Emerald Heart - GIA.jpg", "6.13 - Eme Oval - GRS.jpg", "3.198 - Sapp Oval - IGL.jpg", "5.77 SAPPHIRE CUSH - GRS.jpg", "4.79 - Orange Pink Sapp - GRS.jpg", "3.54 - Sapp Oval - GRS.jpg", "5.44 - Fancy Sapp Cus - GRS.jpg", "3.05 - Yellow Sapp Cus - GRS.jpg", "5.06 SAPPHIRE CUSH - GRS.jpg", "3.36 - Yellow Sapp Cushion - GRS.jpg", "2.07 RUBY CUSH.jpg", "4.64 - Colorless Sapp Cus - GRS.jpg", "7.26 - Eme Round - GRS.jpg", "3.38 - Sapp Oval - IGL.jpg", "3.87 - Sapp Cus - GRS.jpg", "32.37 - Tourmaline Pear - GIA.jpg", "4.64 - SAPPHIRE OVAL - GRS.jpg", "8.28 SAPPHIRE CUSH - GRS.jpg", "3.33 - Pink Orange Sapp Cus - GRS.jpg", "14.51 SAPPHIRE CUSH - GRS.jpg", "3.32 EMERALD OCT NO OIL - GRS.jpg", "2.53 - Sapp Oval- Lotus.jpg", "13.08 SAPPHIRE HEART - GRS.jpg", "PADPARAJA 2.18.jpg", "6.68 - Sapp Cushion - GRS.jpg", "04270.jpg", "04264.jpg", "04265.jpg", "01335.jpg", "01862.jpg", "00959_1.jpg", "04051_1.jpg", "01692.jpg", "02370.jpg", "02562.jpg", "05029_1.jpg", "02454 APPENDIX.jpg", "04054_12 - 1.66cts.jpg", "04054_1 - 2.34cts.jpg", "04157_2.jpg", "05027.jpg", "02725_1.jpg", "02289 - OVAL 1.10 CTS.jpg", "05019.jpg", "01041.jpg", "02548.jpg", "04139.jpg", "01082.jpg", "02681 GRS M2M.jpg", "04168_2.jpg", "01653.jpg", "02400.jpg", "02779_2.jpg", "04171_12 - 3.93cts.jpg", "04054_7 - 1.57cts.jpg", "04267.jpg", "01015-WHITE SAPP 6.68.jpg", "00954.jpg", "04266.jpg", "01336.jpg", "00028.jpg", "04051_2.jpg", "04054_10 - 1.45cts.jpg", "02408_1GUILD.jpg", "02561.jpg", "04138.jpg", "02660 GRS M2M.jpg", "05029_2.jpg", "00809.jpg", "04157_1.jpg", "02763 MISSING.jpg", "04171_5 - 5.37.jpg", "05018.jpg", "01954 MISSING.jpg", "02763.jpg", "02725_2.jpg", "02704.jpg", "01225.jpg", "02923.jpg", "02499_1.jpg", "00584(OLD).jpg", "04131_1.jpg", "04152_2.jpg", "04003.jpg", "04149_1.jpg", "02670.jpg", "02658.jpg", "04229.jpg", "00932.jpg", "02843.jpg", "02856.jpg", "00573(GUILD).jpg", "04541_1.jpg", "03014_1.jpg", "04228.jpg", "04214.jpg", "02665.jpg", "02671.jpg", "00158_2.jpg", "01807.jpg", "03023.jpg", "04016.jpg", "01109_2(missing).jpg", "04002.jpg", "04604.jpg", "04150_1.jpg", "02705.jpg", "04404.jpg", "02908.jpg", "02934.jpg", "01583.jpg", "02499_2.jpg", "04152_1.jpg", "04131_2.jpg", "00514.jpg", "04014.jpg", "02317.jpg", "04054_8 - 1.68cts.jpg", "04598 (Bellerophon).jpg", "04149_2.jpg", "01015-BLUE SAPP 3.10.jpg", "04216.jpg", "02583 MISSING.jpg", "02115.jpg", "02673.jpg", "02897.jpg", "05000_8- 4.70cts.jpg", "02852 APPENDIX.jpg", "02855.jpg", "02869.jpg", "02128.jpg", "04203.jpg", "02672.jpg", "02666.jpg", "00158_1.jpg", "01703 MISSING.jpg", "03020.jpg", "01015-YELLOW SAPP 3.98.jpg", "04029.jpg", "02470.jpg", "04001.jpg", "04015.jpg", "02561-APPENDIX.jpg", "02847 APPENDIX.jpg", "04161.jpg", "04150_2.jpg", "02909.jpg", "02660 .jpg", "00893.jpg", "01233.jpg", "02060.jpg", "02706.jpg", "00105.jpg", "02712.jpg", "04405.jpg", "04401.jpg", "02702.jpg", "02507_1.jpg", "05000_31 - 4.49cts.jpg", "00657_2.jpg", "02919.jpg", "00868.jpg", "04169_2.jpg", "04603.jpg", "04526_1.jpg", "03024.jpg", "04011.jpg", "04005.jpg", "01828.jpg", "04154_2.jpg", "01015-BLUE SAPP 4.08.jpg", "04049_2.jpg", "02892.jpg", "04207.jpg", "02845.jpg", "02689.jpg", "00909.jpg", "04156_1.jpg", "02844.jpg", "00060.jpg", "02289 - PEAR 1.12 CTS.jpg", "04212.jpg", "02724_2.jpg", "02912_2.jpg", "04171_11 - 3.89cts.jpg", "01100_1.jpg", "04004.jpg", "04010.jpg", "02307.jpg", "00585 MISSING.jpg", "03011-2.jpg", "01008.jpg", "02650 NOT IN SYSTEM.jpg", "04602.jpg", "01752.jpg", "02918.jpg", "03057_1.jpg", "02877_2.jpg", "04196_1.jpg", "00898.jpg", "01576.jpg", "02094.jpg", "02533.jpg", "00442.jpg", "02692.jpg", "00605_2.jpg", "00970_1.jpg", "01991.jpg", "02095.jpg", "00899.jpg", "01239.jpg", "02097.jpg", "00992-GUILD.jpg", "01830.jpg", "01745-APPENDIX.jpg", "02120.jpg", "00737.jpg", "00607_2.jpg", "01615(MISSING).jpg", "01995-2.jpg", "02647.jpg", "00605_1.jpg", "01831.jpg", "02588-APPENDIX.jpg", "01170.jpg", "01329(MISSING).jpg", "01038.jpg", "02096.jpg", "01206.jpg", "00096_2.jpg", "02737.jpg", "02092.jpg", "01014.jpg", "02290.jpg", "01955.jpg", "01612.jpg", "01148.jpg", "02066 Appendix.jpg", "00901.jpg", "00097.jpg", "01601-GUILD.jpg", "00992-GRS.jpg", "01175.jpg", "00950_2.jpg", "02093.jpg", "00050_1.jpg", "02734.jpg", "00949_1.jpg", "02085.jpg", "02382(ONLY PHOTO).jpg", "Platinum award 3.jpg", "00057.JPG", "01766_1.jpg", "02641.jpg", "01823.jpg", "01438-RECUT.jpg", "00950_1.jpg", "01610.jpg", "02084.jpg", "00050_2.jpg", "00036_2.jpg", "00019.jpg", "00780.jpg", "01252_2.jpg", "00971.jpg", "02633.jpg", "00597.jpg", "01762-2.jpg", "01676.jpg", "01957_1.jpg", "02586.jpg", "00604_1.jpg", "02551.jpg", "02223.jpg", "00839.jpg", "01995_1.jpg", "02551 platinum award 2.jpg", "00807.jpg", "00036_1.jpg", "01853.jpg", "02383(ONLY PHOTO).jpg", "01252_1.jpg", "00972.jpg", "02878_2.jpg", "01311.jpg", "01681_2.jpg", "01762-1.jpg", "01957_2.jpg", "00604_2.jpg", "02113-Appendix.jpg", "02552.jpg", "01254_2.jpg", "02556.jpg", "01765_2.jpg", "02595.jpg", "00948_1.jpg", "02878_2 bellerophon.jpg", "02384(ONLY PHOTO).jpg", "02386.jpg", "00591.jpg", "00552.jpg", "02225.jpg", "02557.jpg", "00586_1.jpg", "01741_2.jpg", "01248.jpg", "00953_1.jpg", "01262.jpg", "01798_1.jpg", "01060.jpg", "02227.jpg", "02582.jpg", "02596.jpg", "01765_1.jpg", "01109_1. SOLD.jpg", "00550.jpg", "01855.jpg", "01302.jpg", "02637.jpg", "02151.jpg", "02636.jpg", "00592.jpg", "01868.jpg", "02597.jpg", "SOLD 00671_1.jpg", "00586_2.jpg", "01741_1.jpg", "01138_1.jpg", "01078.jpg", "01722.jpg", "01796_2.jpg", "01319_1.jpg", "00010.jpg", "00776.jpg", "01332.jpg", "SOLD 00154.jpg", "01121_1.jpg", "02551 platinum award1.jpg", "01535.jpg", "02764.jpg", "01138_2.jpg", "02572.jpg", "00840_1.jpg", "00417.jpg", "01018_2.jpg", "00775.jpg", "01331.jpg", "01480.jpg", "01330.jpg", "02639.jpg", "00774.jpg", "00589.jpg", "01018_3.jpg", "01052.jpg", "01046.jpg", "01278.jpg", "02563.jpg", "02588.jpg", "01693.jpg", "01863.jpg", "01186_1.jpg", "02167.jpg", "00764.jpg", "01491.jpg", "01613_1.jpg", "01792_1.jpg", "01170 (Appendix).jpg", "01447.jpg", "01573(MISSING).jpg", "01694_2.jpg", "00942_2.jpg", "02238.jpg", "02586-APPENDIX.jpg", "01269.jpg", "02762.jpg", "01294.jpg", "01055.jpg", "02560.jpg", "01848.jpg", "01186_2.jpg", "01451.jpg", "00941.jpg", "00996.jpg", "01694_1.jpg", "00942_1.jpg", "01040.jpg", "01256.jpg", "00891.jpg", "00517.jpg", "02878_1 bellerophon.jpg", "01806.jpg", "01797_1.jpg", "01393.jpg", "02014_1.jpg", "01422.jpg", "01191.jpg", "01973.jpg", "01998.jpg", "01120_2.jpg", "02088.jpg", "00106.jpg", "02063.jpg", "02061.jpg", "01971.jpg", "02014_3.jpg", "02014_2.jpg", "01795_1.jpg", "00717.jpg", "01192.jpg", "01179.jpg", "00850.jpg", "02533-APPENDIX.jpg", "02048.jpg", "01601-GRS.jpg", "01999-2.jpg", "02724_3.jpg", "02677.jpg", "00538.jpg", "01958_2.jpg", "02556-APPENDIX.jpg", "01020.jpg", "02059.jpg", "00894.jpg", "02582 (1).jpg", "01630.jpg", "01999-1.jpg", "00738.jpg", "01432.jpg", "02113.jpg", "02551 Platinum award.jpg", "01203_1.jpg", "01958_1.jpg", "01745.jpg", "01989.jpg", "01590.jpg", "01800_1.jpg", "01578.jpg", "00896.jpg", "00882.jpg", "01220.jpg", "02846 MISSING.jpg", "02067.jpg", "04402.jpg", "00657_1.jpg", "02507_2.jpg", "04169_1.jpg", "04171_9 - 4.35cts.jpg", "04600.jpg", "04199.jpg", "IMG_7576.jpg", "IMG_7577.jpg", "IMG_7603.jpg", "04006.jpg", "00512.jpg", "04012.jpg", "04154_1.jpg", "01015-BLUE SAPP 3.16.jpg", "04049_1.jpg", "04562.jpg", "02675.jpg", "04204.jpg", "04171_8-5.14cts.jpg", "02852.jpg", "02846 APPENDIX.jpg", "02847.jpg", "04156_2.jpg", "02289 - OVAL 1.35 CTS.jpg", "01369.jpg", "01433.jpg", "02724_1.jpg", "02648.jpg", "01015-YELLOW SAPP 4.68.jpg", "02912_1.jpg", "05000_27 - 4.31cts.jpg", "04013.jpg", "01100_2.jpg", "03032.jpg", "00498.jpg", "04198.jpg", "04054_11 - 1.54cts.jpg", "04015_1.jpg", "04601.jpg", "01051-SSEF.jpg", "03057_2.jpg", "02877_1.jpg", "02486_2.jpg", "00671.jpg", "02700.jpg", "01221.jpg", "01235.jpg", ]

apps/recallassess/recallassess-api/src/api/shared/decorators/client-auth.decorator.ts

ClientAuth
Type : unknown
Default value : createParamDecorator(() => { // Get current request context const requestContext = getCLRequestContext(); if (!requestContext) { throw new UnauthorizedException("Client request context not available. Ensure middleware is applied."); } const participant = requestContext.participantLoggedIn; if (!participant) { throw new UnauthorizedException("Authentication required"); } // Return auth data return { companyId: participant.company_id, participantId: participant.id, participant: participant, email: participant.email, firstName: participant.first_name, lastName: participant.last_name, fullName: `${participant.first_name} ${participant.last_name}`.trim(), role: participant.role, isActive: participant.is_active, }; })

Parameter decorator to inject authenticated client (participant) data into controller methods

apps/recallassess/recallassess-api/src/config/navigation/client-navigation.config.ts

clientNavigationConfig
Type : RoleBasedNavigationConfig
Default value : { name: "default", items: [ { id: "dashboard", title: "Dashboard", type: "basic", icon: "pi pi-home", routerLink: "/portal/dashboard", // Available to all roles (no restriction) allowedRoles: [ParticipantRole.PARTICIPANT, ParticipantRole.PARTICIPANT_ADMIN], metadata: { trackingId: "nav_dashboard", }, }, { id: "participant", title: "Participants", type: "basic", icon: "pi pi-users", routerLink: "/portal/participant", // Admin only allowedRoles: [ParticipantRole.PARTICIPANT_ADMIN], metadata: { trackingId: "nav_participants", helpText: "Manage participant contacts and invitations", }, }, { id: "license-allocations", title: "License Allocations", type: "basic", icon: "pi pi-chart-bar", routerLink: "/portal/license-allocations", // Admin only allowedRoles: [ParticipantRole.PARTICIPANT_ADMIN], metadata: { trackingId: "nav_licenses", helpText: "View and manage license allocations", }, }, { id: "my-courses", title: "My Courses", type: "basic", icon: "pi pi-book", routerLink: "/portal/my-courses", // Available to all roles allowedRoles: [ParticipantRole.PARTICIPANT, ParticipantRole.PARTICIPANT_ADMIN], metadata: { trackingId: "nav_my_courses", }, }, { id: "course", title: "Courses Info", type: "basic", icon: "pi pi-bookmark", routerLink: "/portal/course", // Admin only allowedRoles: [ParticipantRole.PARTICIPANT_ADMIN], metadata: { trackingId: "nav_courses", helpText: "Browse and manage course catalog", }, }, { id: "reports", title: "Reports", type: "basic", icon: "pi pi-file", routerLink: "/portal/reports", // Admin only allowedRoles: [ParticipantRole.PARTICIPANT_ADMIN], metadata: { trackingId: "nav_reports", helpText: "Access system reports and analytics", }, }, { id: "settings", title: "Settings", type: "basic", icon: "pi pi-cog", routerLink: "/portal/settings", // Admin only allowedRoles: [ParticipantRole.PARTICIPANT_ADMIN], metadata: { trackingId: "nav_settings", }, }, ], }

Client Portal PWA Navigation Configuration Used by: recallassess-pwa (client portal) Implements role-based access control for navigation items

Role Configuration:

  • PARTICIPANT: Basic user - can only see Dashboard and My Courses
  • PARTICIPANT_ADMIN: Admin user - can see all navigation items including Settings

To expand:

  1. Add new navigation items to the items array
  2. Set allowedRoles array to control access
  3. Use featureFlags for conditional rendering
  4. Add metadata for badges, tracking, etc.

apps/recallassess/recallassess-api/src/api/shared/timezone/company-timezone.service.ts

COUNTRY_TO_TIMEZONE
Type : Record<string, string>
Default value : { AE: "Asia/Dubai", AU: "Australia/Sydney", IN: "Asia/Kolkata", US: "America/New_York", GB: "Europe/London", UK: "Europe/London", DE: "Europe/Berlin", FR: "Europe/Paris", CA: "America/Toronto", BR: "America/Sao_Paulo", JP: "Asia/Tokyo", CN: "Asia/Shanghai", SG: "Asia/Singapore", HK: "Asia/Hong_Kong", MY: "Asia/Kuala_Lumpur", SA: "Asia/Riyadh", EG: "Africa/Cairo", ZA: "Africa/Johannesburg", NG: "Africa/Lagos", KE: "Africa/Nairobi", RU: "Europe/Moscow", NL: "Europe/Amsterdam", ES: "Europe/Madrid", IT: "Europe/Rome", CH: "Europe/Zurich", SE: "Europe/Stockholm", PL: "Europe/Warsaw", TR: "Europe/Istanbul", PK: "Asia/Karachi", BD: "Asia/Dhaka", ID: "Asia/Jakarta", TH: "Asia/Bangkok", VN: "Asia/Ho_Chi_Minh", PH: "Asia/Manila", KR: "Asia/Seoul", NZ: "Pacific/Auckland", MX: "America/Mexico_City", AR: "America/Argentina/Buenos_Aires", CL: "America/Santiago", CO: "America/Bogota", PT: "Europe/Lisbon", IE: "Europe/Dublin", BE: "Europe/Brussels", AT: "Europe/Vienna", NO: "Europe/Oslo", DK: "Europe/Copenhagen", FI: "Europe/Helsinki", GR: "Europe/Athens", IL: "Asia/Jerusalem", QA: "Asia/Qatar", KW: "Asia/Kuwait", BH: "Asia/Bahrain", OM: "Asia/Muscat", JO: "Asia/Amman", LB: "Asia/Beirut", SY: "Asia/Damascus", IQ: "Asia/Baghdad", IR: "Asia/Tehran", }

Maps ISO 3166-1 alpha-2 country codes to a primary IANA timezone. Used to derive company timezone from Company.country when preferred_timezone is not set. Source: IANA tzdb / common conventions (one primary zone per country where applicable).

Keep in sync with libs/recallassess/shared-ng/src/lib/utils/company-timezone.utils.ts

DEFAULT_TIMEZONE
Type : string
Default value : "UTC"

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/utils/mappings/quiz-to-knowledge-review.mapping.ts

COURSE_CODE_TO_KNOWLEDGE_REVIEW_MAP
Type : Record<string, literal type>
Default value : { // PIP-Online is excluded - it uses JSON-based migration (pip-online-knowledge-review.migration.ts) // "PIP-Online": { // newCourseCode: "PIP", // }, // CSM-Online is excluded - it uses JSON-based migration (csm-online-knowledge-review.migration.ts) // "CSM-Online": { // newCourseCode: "CSM", // }, "CCT-Online": { newCourseCode: "CCT", }, // DNP-Online is excluded - it uses JSON-based migration (dnp-online-knowledge-review.migration.ts) // "DNP-Online": { // newCourseCode: "DNP", // }, }

Maps course codes to knowledge review settings. All quizzes for the specified legacy course codes will be migrated as knowledge reviews.

Structure: { "legacy-course-code": { newCourseCode: string, // The new course code (e.g., "PIP" for "PIP-Online") } }

apps/recallassess/recallassess-api/src/scripts/lms-migration/cleanup-legacy-quizzes.ts

COURSE_CODES
Type : []
Default value : ["CCT-Online", "CSM-Online", "PIP-Online"]
EXCLUDED_QUIZ_TITLE
Type : string
Default value : "PERSUASION & INFLUENCING PSYCHOLOGY KNOWLEDGE REVIEW"

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/utils/mappings/course-knowledge-review.mapping.ts

COURSE_KNOWLEDGE_REVIEW_MAP
Type : Record<string, string>
Default value : { PIP: "PERSUASION & INFLUENCING PSYCHOLOGY KNOWLEDGE REVIEW", CCT: "COLLABORATIVE COMMUNICATION TECHNIQUES KNOWLEDGE REVIEW", // Add more course knowledge review mappings here as needed // Example: // CSM: "CONSULTATIVE SELLING MASTERY KNOWLEDGE REVIEW", }

Maps course codes to their main knowledge review quiz title. This knowledge review will be set as the course.knowledge_review_id during migration.

Structure: { "COURSE-CODE": "Knowledge Review Quiz Title" }

apps/recallassess/recallassess-api/src/api/admin/course-module-page/dto/course-module-page-difficulty-level.constants.ts

COURSE_MODULE_PAGE_DIFFICULTY_LEVEL_VALUES
Type : unknown
Default value : [ "FOUNDATION", "INTERMEDIATE", "ADVANCED", "EXPERT", ] as const satisfies readonly DifficultyLevel[]

Matches Prisma DifficultyLevel. Listed explicitly so @IsIn accepts EXPERT even when a deployed bundle has an outdated @prisma/client where Object.keys(DifficultyLevel) is still length 3.

apps/recallassess/recallassess-api/src/config/course-progress.config.ts

COURSE_PROGRESS_CONFIG
Type : unknown
Default value : { /** * PRE BAT completion percentage */ PRE_BAT: 10, /** * E-LEARNING total percentage (distributed across modules) */ E_LEARNING: 70, /** * KNOWLEDGE REVIEW completion percentage */ KNOWLEDGE_REVIEW: 10, /** * POST BAT completion percentage */ POST_BAT: 10, } as const

Course Progress Configuration

Centralized configuration for course progress percentages. These values define the completion percentage at each stage. Update these values in one place to affect all progress calculations.

Total: 10% (PRE BAT) + 70% (E-LEARNING) + 10% (KNOWLEDGE REVIEW) + 10% (POST BAT) = 100%

--- How progress bar is calculated (all places) ---

  1. SOURCE OF TRUTH: This file (COURSE_PROGRESS_CONFIG + COURSE_PROGRESS_PERCENTAGES).
  2. API: course-progress-status.listener.ts uses these constants to set learning_group_participant.completion_percentage when each stage completes (PRE_BAT_COMPLETED, E_LEARNING_COMPLETED, KNOWLEDGE_REVIEW_COMPLETED, POST_BAT_COMPLETED).
  3. E-LEARNING: Within the E_LEARNING share (70%), progress is (modules_completed / total_modules) * E_LEARNING.
  4. STORAGE: completion_percentage is stored on learning_group_participant and returned by client APIs (my-course, dashboard, assessment, etc.).
  5. DISPLAY: PWA and Admin PWA show the progress bar using completion_percentage from the API (e.g. course detail, course list, dashboard). No client-side recalculationβ€”always use API value.

--- Backfilling old courses after changing weights --- Existing rows keep their stored completion_percentage until recalculated. To update all enrollments to the current weights (e.g. after changing 25/40/10/25 β†’ 10/70/10/10), run: npx nx run recallassess-api:recalculate-course-progress Optional: --dry-run to preview, --course-id= or --company-id= to limit scope.

COURSE_PROGRESS_PERCENTAGES
Type : unknown
Default value : { /** After PRE BAT completion: 10% */ AFTER_PRE_BAT: COURSE_PROGRESS_CONFIG.PRE_BAT, /** After E-LEARNING completion: 10% + 70% = 80% */ AFTER_E_LEARNING: COURSE_PROGRESS_CONFIG.PRE_BAT + COURSE_PROGRESS_CONFIG.E_LEARNING, /** After KNOWLEDGE REVIEW completion: 10% + 70% + 10% = 90% */ AFTER_KNOWLEDGE_REVIEW: COURSE_PROGRESS_CONFIG.PRE_BAT + COURSE_PROGRESS_CONFIG.E_LEARNING + COURSE_PROGRESS_CONFIG.KNOWLEDGE_REVIEW, /** After POST BAT completion: 10% + 70% + 10% + 10% = 100% */ AFTER_POST_BAT: COURSE_PROGRESS_CONFIG.PRE_BAT + COURSE_PROGRESS_CONFIG.E_LEARNING + COURSE_PROGRESS_CONFIG.KNOWLEDGE_REVIEW + COURSE_PROGRESS_CONFIG.POST_BAT, } as const

Cumulative completion percentage at each stage (derived from COURSE_PROGRESS_CONFIG). Used by course-progress-status.listener to set completion_percentage when stages complete.

apps/recallassess/recallassess-api/src/api/client/learning-group/events/course-progress.events.ts

COURSE_PROGRESS_EVENTS
Type : unknown
Default value : { PRE_BAT_COMPLETED: "course.progress.pre_bat.completed", E_LEARNING_COMPLETED: "course.progress.e_learning.completed", KNOWLEDGE_REVIEW_COMPLETED: "course.progress.knowledge_review.completed", POST_BAT_COMPLETED: "course.progress.post_bat.completed", COURSE_COMPLETED: "course.progress.course.completed", } as const

Course Progress Events These events are emitted when course progress milestones are reached Subscribers can listen and react to these events to update participant status

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/utils/mappings/course-section-to-module.mapping.ts

COURSE_SECTION_TO_MODULE_MAP
Type : Record<string, Record<string, string>>
Default value : { "CCT-Online": { "Level 1: Course Introduction": "CCT-Intro", "Level 2: The Communication Continuum": "CCT08", "Level 3: The 5 Dimensions of Critical Communications": "CCT05", "Level 4: Communicative Slience and Violence": "CCT02", "Level 5: The Mutual Understanding Question": "CCT09", "Level 6: Maintaining Dialogue": "CCT03", "Level 7: Storyboarding": "CCT04", "Level 8: How to Hold Difficult & Sensitive Conversations": "CCT06", "Level 9: Communicative Safety 1": "CCT07", "Level 10: Communicative Safety 2": "CCT07", }, "PIP-Online": { "Level 1: Course Introduction": "PIP-Intro", "Level 2: The Principle of Contrast (Intro)": "PIP11", "Level 3: The Principle of Contrast (Application)": "PIP12", "Level 4: The 4 Step Process": "PIP13", "Level 5. Reciprocity #1": "PIP01", "Level 6: Reciprocity #2": "PIP02", "Level 7: Consistency - Activating": "PIP03", "Level 8: Consistency - Amplifying": "PIP04", "Level 9: Consensus": "PIP05", "Level 10: Liking - Activating": "PIP06", "Level 11: Liking - Amplifying": "PIP07", "Level 12: Authority": "PIP08", "Level 13: Scarcity - Activating": "PIP09", "Level 14: Scarcity - Amplifying": "PIP10", "Level 15: Contrast Revisited": "PIP14", }, "DNP-Online": { // DNP-Online maps to DNP-ONLINE course "Level 1: Negotiation Formats": "TSN01", "Level 2: Negotiation Preparation": "TSN02", "Level 3: Body Language & Negotiation Power": "TSN13", "Level 4: Getting Beyond Price": "TSN03", "Level 5: Challenging Assumptions": "DNP02", "Level 6: BATNA & Bottom Line": "TSN04", "Level 7: The Importance of Constituency": "TSN06", "Level 8: The Fixed Pie Mindset & Creating Value": "TSN07", "Level 9: Dovetailing Interests": "TSN08", "Level 10: Making the First Offer": "TSN09", "Level 11: Using ZOPA to your Advantage": "TSN10", "Level 12: The Information Game": "DNP03", "Level 13: Putting it all together": "DNP-Conclusion", }, "CSM-Online": { "Level 1: Course Introduction": "CSM-Intro", "Level 2: Sales Psychology": "CSM01", "Level 3: Understanding Consultative Selling": "CSM02", "Level 4: The 5-Step Sales Process": "CSM03", "Level 5: Rapport Building": "HIC14", "Level 6: Words Part 1": "HIC12", "Level 7: Words Part 2": "HIC13", "Level 8: Tonality": "HIC03", "Level 9: Body Language": "HIC06", "Level 10: Maslows Hierarchy in Sales": "CSM04", "Level 11: Active Listening": "HIC11", "Level 12: Level 1 & 2 Questions": "CSM05", "Level 13: The Conditional Close & Level 3 Questions": "CSM06", "Level 14: Managing Objections": "CSM07", "Level 15: Dealing with Price Resistance": "CSM08", "Level 16: Compelling Events": "CSM09", "Level 17: The Psychology of Closing": "CSM10", "Level 18: Closing Techniques Weaponry": "CSM11", }, }

Maps legacy course section titles to new course module codes.

Structure: { "legacy-course-code": { "section-title": "course-module-code" } }

apps/recallassess/recallassess-api/src/api/admin/course/config/course-links-config.service.ts

courseLinks
Type : LinkConfig[]
Default value : [getCourseModulePageLink()]

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/course-media.migration.ts

courseMediaMigration
Type : MigrationDefinition<LegacyMediaRow, CourseMediaPayload>
Default value : { name: "course-media", sourceQuery: ` SELECT m.media_id, m.room_name, m.record_type, m.media_type, m.file_name, m.actual_file_name, c.course_code FROM media m INNER JOIN course c ON m.record_id = c.course_id WHERE m.room_name = 'courseMgt_course' AND m.record_type = 'picture' AND c.course_code IN ('PIP-Online', 'CSM-Online', 'CCT-Online', 'DNP-Online') ORDER BY c.course_code, m.media_id `, transform: async (row, context): Promise<CourseMediaPayload | null> => { const legacyCode = String(row.course_code ?? "").trim(); if (!legacyCode) { return null; } // Get the new course code mapping (from courses migration) const courseCodeMap: Record<string, string> = { "DNP-Online": "DNP", "CCT-Online": "CCT", "PIP-Online": "PIP", "CSM-Online": "CSM", }; const newCourseCode = courseCodeMap[legacyCode]; if (!newCourseCode) { console.warn(`⚠️ Skipping media ID ${row.media_id} for course ${legacyCode}: no mapping found`); return null; } // Filter by --only-course if specified if (context.onlyCourse && newCourseCode.toUpperCase() !== context.onlyCourse.toUpperCase()) { return null; // Skip this course silently } // Get course ID from PostgreSQL const courseDelegate = getCourseDelegate(context.prisma); const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: newCourseCode }, select: { id: true }, }); if (!course) { console.warn( `⚠️ Skipping media ID ${row.media_id} for course ${legacyCode} (${newCourseCode}): course not found in PostgreSQL. Run courses migration first.`, ); return null; } // Map room_name + record_type to MediaName enum // Only migrate records that map to COURSE__IMAGE const mediaNameEnum = mapMediaNameEnum(row.room_name, row.record_type); if (!mediaNameEnum || mediaNameEnum !== "COURSE__IMAGE") { console.warn( `⚠️ Skipping media ID ${row.media_id} for course ${legacyCode}: room_name='${row.room_name}', record_type='${row.record_type}' does not map to COURSE__IMAGE`, ); return null; } // Map media_type to MediaType enum const mediaType = mapMediaType(row.media_type); // For legacy path: use file_name directly (format: "{media_id}_{actual_file_name}") // For new path: use actual_file_name (just the filename without media_id prefix) const legacyFileName = row.file_name; // Already has format: {media_id}_{actual_file_name} const newFileName = row.actual_file_name || row.file_name; // Just the filename // Build paths const legacyPath = buildLegacyPath(mediaType, legacyFileName); const newS3Path = buildNewS3Path(course.id, row.media_id, newFileName); const mediaPath = buildMediaPath(course.id, row.media_id, newFileName); return { course_code: newCourseCode, course_id: course.id, media_id: row.media_id, legacy_path: legacyPath, new_s3_path: newS3Path, media_path: mediaPath, media_name: newFileName, media_type: mediaType, media_name_enum: mediaNameEnum, }; }, upsert: async (payload, context): Promise<void> => { // Re-read environment variables to ensure they're loaded const legacyBucket = optionalEnv("LEGACY_S3_BUCKET", "") || undefined; const legacyRegionOverride = optionalEnv("LEGACY_AWS_REGION", ""); const legacyAwsRegion = legacyRegionOverride !== "" ? legacyRegionOverride : optionalEnv("AWS_REGION", "ap-southeast-1"); const legacyAwsAccessKeyId = optionalEnv("LEGACY_AWS_ACCESS_KEY_ID", "") || undefined; const legacyAwsSecretAccessKey = optionalEnv("LEGACY_AWS_SECRET_ACCESS_KEY", "") || undefined; // Debug: Log what we're actually using console.log(` [DEBUG] Environment check in upsert:`); console.log(` LEGACY_S3_BUCKET: ${legacyBucket}`); console.log(` LEGACY_AWS_REGION: ${legacyAwsRegion}`); console.log(` LEGACY_AWS_ACCESS_KEY_ID: ${legacyAwsAccessKeyId?.substring(0, 8)}...`); console.log( ` LEGACY_AWS_SECRET_ACCESS_KEY: ${legacyAwsSecretAccessKey ? "SET (" + legacyAwsSecretAccessKey.length + " chars)" : "NOT SET"}`, ); const newBucket = optionalEnv("AWS_S3_MEDIA_BUCKET", "") || undefined; const newAwsRegion = optionalEnv("AWS_REGION", "ap-southeast-1"); const newAwsAccessKeyId = optionalEnv("AWS_ACCESS_KEY_ID", "") || undefined; const newAwsSecretAccessKey = optionalEnv("AWS_SECRET_ACCESS_KEY", "") || undefined; if (!legacyBucket || !newBucket) { throw new Error("LEGACY_S3_BUCKET and AWS_S3_MEDIA_BUCKET environment variables are required"); } if (!legacyAwsAccessKeyId || !legacyAwsSecretAccessKey) { throw new Error( "LEGACY_AWS_ACCESS_KEY_ID and LEGACY_AWS_SECRET_ACCESS_KEY are required for accessing the legacy bucket", ); } if (!newAwsAccessKeyId || !newAwsSecretAccessKey) { throw new Error("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required for accessing the new bucket"); } // Initialize S3 client for legacy bucket (legacy AWS account) const legacyS3Config = { region: legacyAwsRegion, credentials: { accessKeyId: legacyAwsAccessKeyId, secretAccessKey: legacyAwsSecretAccessKey, }, }; const legacyS3Client = new S3Client(legacyS3Config); // Log credential info for debugging (without exposing secrets) if (!context.dryRun) { console.log( ` Using legacy bucket credentials: AccessKeyId=${legacyAwsAccessKeyId?.substring(0, 8)}... (region: ${legacyAwsRegion}, bucket: ${legacyBucket})`, ); console.log( ` Credentials check: ${legacyAwsAccessKeyId ? "βœ“ AccessKeyId set" : "βœ— Missing"} | ${legacyAwsSecretAccessKey ? "βœ“ SecretAccessKey set" : "βœ— Missing"}`, ); } // Verify credentials work by testing bucket access (only once per migration run) const contextWithFlag = context as unknown as { _legacyBucketVerified?: boolean }; if (!context.dryRun && !contextWithFlag._legacyBucketVerified) { try { // Try to list objects in the bucket (with limit 1) to verify access const { ListObjectsV2Command } = await import("@aws-sdk/client-s3"); await legacyS3Client.send( new ListObjectsV2Command({ Bucket: legacyBucket, MaxKeys: 1, }), ); contextWithFlag._legacyBucketVerified = true; console.log(` βœ“ Verified access to legacy bucket: ${legacyBucket}`); } catch (verifyError: unknown) { const err = verifyError as { message?: string; $metadata?: { httpStatusCode?: number } }; console.error(` ⚠️ Failed to verify access to legacy bucket: ${legacyBucket}`); console.error(` Error: ${err.message || String(verifyError)}`); if (err.$metadata?.httpStatusCode === 403) { console.error( ` This indicates a permissions issue. Check that the credentials in .env.uat have s3:ListBucket permission.`, ); } // Don't throw - let the actual file operation fail with better error message } } // Initialize S3 client for new bucket (new AWS account) const newS3Config: { region: string; credentials: { accessKeyId: string; secretAccessKey: string; }; } = { region: newAwsRegion, credentials: { accessKeyId: newAwsAccessKeyId, secretAccessKey: newAwsSecretAccessKey, }, }; const newS3Client = new S3Client(newS3Config); const sourceKey = payload.legacy_path; const destinationKey = payload.new_s3_path; // Full S3 path including private/ prefix if (context.verbose) { console.log(`\n [DEBUG] Starting file copy for: ${sourceKey}`); console.log(` [DEBUG] Source: s3://${legacyBucket}/${sourceKey}`); console.log(` [DEBUG] Destination: s3://${newBucket}/${destinationKey}`); } try { // Check if file already exists in new bucket let fileExists = false; try { if (context.verbose) { console.log(` [DEBUG] Checking if file exists in new bucket (${newBucket})...`); } await newS3Client.send( new HeadObjectCommand({ Bucket: newBucket, Key: destinationKey, }), ); fileExists = true; if (context.verbose) { console.log(` [DEBUG] File already exists in new bucket`); } } catch (headError: unknown) { const error = headError as { name?: string; $metadata?: { httpStatusCode?: number }; code?: string; message?: string; }; if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) { if (context.verbose) { console.log(` [DEBUG] File does not exist in new bucket, will copy`); } } else { console.error(` [DEBUG] Error checking new bucket:`, { name: error.name, code: error.code, httpStatusCode: error.$metadata?.httpStatusCode, message: error.message, }); if (error.$metadata?.httpStatusCode === 403) { console.error(`\n ⚠️ PERMISSION ERROR: Cannot access NEW bucket (403 Forbidden)`); console.error(` The credentials for the NEW bucket do not have permission.`); console.error(` Check AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for bucket: ${newBucket}`); console.error(` Required permissions: s3:HeadObject and s3:PutObject\n`); } throw headError; } } // Copy file if it doesn't exist // Since buckets are in different AWS accounts, we need to download from legacy bucket // and upload to new bucket (CopyObject only works within same account or with cross-account permissions) if (!fileExists) { if (context.dryRun) { console.log( `[DRY-RUN] Would copy: s3://${legacyBucket}/${sourceKey} β†’ s3://${newBucket}/${destinationKey}`, ); } else { // First, verify the object exists and is accessible try { console.log(` [DEBUG] Step 1: Checking object metadata with HeadObjectCommand: ${sourceKey}`); const headResult = await legacyS3Client.send( new HeadObjectCommand({ Bucket: legacyBucket, Key: sourceKey, }), ); console.log( ` βœ“ HeadObject check passed for: ${sourceKey} (ContentLength: ${headResult.ContentLength})`, ); } catch (headError: unknown) { console.error(` [DEBUG] HeadObjectCommand failed for: ${sourceKey}`); const headErr = headError as { $metadata?: { httpStatusCode?: number }; name?: string; code?: string; message?: string; }; console.error(` [DEBUG] Error details:`, { httpStatusCode: headErr.$metadata?.httpStatusCode, name: headErr.name, code: headErr.code, message: headErr.message, }); if ( headErr.$metadata?.httpStatusCode === 403 || headErr.name === "AccessDenied" || headErr.code === "403" || headErr.code === "AccessDenied" ) { console.error(`\n ⚠️ PERMISSION ERROR: Cannot access object metadata (403 Forbidden)`); console.error( ` The credentials CAN list the bucket but CANNOT check object metadata (s3:HeadObject permission missing).`, ); console.error(` Object path: ${sourceKey}`); console.error(` Required IAM policy actions: s3:HeadObject and s3:GetObject`); console.error(` Required IAM policy resource: "arn:aws:s3:::${legacyBucket}/*"`); console.error( ` Current permissions: βœ“ s3:ListBucket, βœ— s3:HeadObject (MISSING), βœ— s3:GetObject (MISSING)\n`, ); // Re-throw to be caught by outer handler throw headError; } else if (headErr.$metadata?.httpStatusCode === 404 || headErr.name === "NotFound") { console.warn(` ⚠️ Object does not exist: ${sourceKey}`); return; // Skip this file } console.error(` [DEBUG] Re-throwing non-403/404 error from HeadObjectCommand`); throw headError; // Re-throw other errors } // Download from legacy bucket let getObjectResponse: GetObjectCommandOutput; try { console.log(` Attempting to download: ${sourceKey}`); getObjectResponse = await legacyS3Client.send( new GetObjectCommand({ Bucket: legacyBucket, Key: sourceKey, }), ); console.log(` βœ“ GetObject succeeded for: ${sourceKey}`); } catch (getError: unknown) { const getErr = getError as { $metadata?: { httpStatusCode?: number }; name?: string; code?: string }; if ( getErr.$metadata?.httpStatusCode === 403 || getErr.name === "AccessDenied" || getErr.code === "403" ) { console.error(`\n ⚠️ PERMISSION ERROR: Cannot download object (403 Forbidden)`); console.error( ` The credentials CAN list the bucket and check object metadata, but CANNOT download the file.`, ); console.error(` Object path: ${sourceKey}`); console.error(` Required IAM policy action: s3:GetObject`); console.error( ` Required IAM policy resource: "arn:aws:s3:::${legacyBucket}/*" (or specific path: "arn:aws:s3:::${legacyBucket}/${sourceKey}")`, ); console.error( ` Current permissions: βœ“ s3:ListBucket, βœ“ s3:HeadObject, βœ— s3:GetObject (MISSING)\n`, ); // Re-throw to be caught by outer handler throw getError; } throw getError; // Re-throw other errors } // Convert stream to buffer const chunks: Uint8Array[] = []; if (getObjectResponse.Body) { for await (const chunk of getObjectResponse.Body as AsyncIterable<Uint8Array>) { chunks.push(chunk); } } const fileBuffer = Buffer.concat(chunks); // Upload to new bucket await newS3Client.send( new PutObjectCommand({ Bucket: newBucket, Key: destinationKey, Body: fileBuffer, ContentType: getObjectResponse.ContentType, Metadata: getObjectResponse.Metadata, }), ); console.log( `βœ“ Course ${payload.course_code} (Media ID ${payload.media_id}): Copied s3://${legacyBucket}/${sourceKey} β†’ s3://${newBucket}/${destinationKey}`, ); } } else { if (!context.dryRun) { console.log( `βœ“ Course ${payload.course_code} (Media ID ${payload.media_id}): File already exists, skipped: ${payload.media_path}`, ); } } // Create or update Media record in PostgreSQL if (!context.dryRun) { // Check if media record already exists for this course and path const existingMedia = await context.prisma.media.findFirst({ where: { course_id: payload.course_id, media_path: payload.media_path, // Use media_path (without private/ prefix) }, }); if (existingMedia) { // Update existing record await context.prisma.media.update({ where: { id: existingMedia.id }, data: { media_name: payload.media_name_enum, media_type: payload.media_type, }, }); console.log( `βœ“ Course ${payload.course_code} (Media ID ${payload.media_id}): Updated Media record (DB ID: ${existingMedia.id}, path: ${payload.media_path})`, ); } else { // Create new record - catch ID conflicts and retry with findFirst check try { const createdMedia = await context.prisma.media.create({ data: { media_path: payload.media_path, // Store path without private/ prefix media_name: payload.media_name_enum, media_type: payload.media_type, course_id: payload.course_id, module: "course", // lowercase as per schema is_public: false, // Course media is private (in private/ folder) }, }); console.log( `βœ“ Course ${payload.course_code} (Media ID ${payload.media_id}): Created Media record (DB ID: ${createdMedia.id}, path: ${payload.media_path})`, ); } catch (createError: unknown) { const err = createError as { code?: string; meta?: { target?: string[] } }; // If unique constraint on ID, check if record exists and update instead if (err.code === "P2002" && err.meta?.target?.includes("id")) { // Record might have been created concurrently, try to find and update const existing = await context.prisma.media.findFirst({ where: { course_id: payload.course_id, media_path: payload.media_path, }, }); if (existing) { await context.prisma.media.update({ where: { id: existing.id }, data: { media_name: payload.media_name_enum, media_type: payload.media_type, }, }); } else { // Re-throw if it's a different issue throw createError; } } else { throw createError; } } } } } catch (error: unknown) { const err = error as { message?: string; name?: string; code?: string; $metadata?: { httpStatusCode?: number; requestId?: string; }; }; const errorMessage = err.message || String(error); const errorCode = err.code || err.name || "UnknownError"; const httpStatusCode = err.$metadata?.httpStatusCode; const requestId = err.$metadata?.requestId; const legacyFullUrl = `s3://${legacyBucket}/${sourceKey}`; const newFullUrl = `s3://${newBucket}/${destinationKey}`; console.error( `❌ Course ${payload.course_code} (Media ID ${payload.media_id}): Failed to copy ${sourceKey}`, ); console.error(` Error: ${errorMessage}`); console.error(` Error Code: ${errorCode}`); if (httpStatusCode) { console.error(` HTTP Status: ${httpStatusCode}`); } if (requestId) { console.error(` Request ID: ${requestId}`); } console.error(` Legacy URL: ${legacyFullUrl}`); console.error(` New URL: ${newFullUrl}`); // Handle specific error codes if ( httpStatusCode === 403 || err.name === "AccessDenied" || err.code === "AccessDenied" || errorCode === "403" ) { console.error(`\n ⚠️ PERMISSION DENIED (403 Forbidden)`); console.error(` The AWS credentials do not have permission to access the legacy bucket.`); console.error(` Check the following:`); console.error(` 1. LEGACY_AWS_ACCESS_KEY_ID and LEGACY_AWS_SECRET_ACCESS_KEY are correct`); console.error(` 2. The IAM user/role has s3:GetObject permission for bucket: ${legacyBucket}`); console.error(` 3. The bucket policy allows access from these credentials`); console.error(` 4. The credentials are for the correct AWS account`); console.error(`\n Full error details:`, error); throw error; } if (err.name === "NoSuchKey" || err.code === "NoSuchKey" || httpStatusCode === 404) { console.warn(` File does not exist in source bucket: ${legacyFullUrl}`); // Continue with other files - don't create DB record if file doesn't exist return; } // Log full error for other errors console.error(` Full error:`, error); // Continue with other files for other errors } }, }

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/course.migration.ts

courseMigration
Type : MigrationDefinition<LegacyCourseRow, CoursePayload>
Default value : { name: "course", sourceQuery: ` SELECT course_code, title, description_short, description FROM course WHERE course_code IN ('PIP-Online', 'CSM-Online', 'CCT-Online', 'DNP-Online') ORDER BY course_code `, transform: (row, context): CoursePayload | null => { const legacyCode = String(row.course_code ?? "").trim(); const courseOverrides = getCourseOverrides(context.environment); const override = courseOverrides[legacyCode]; if (!override) { return null; } // Process short_description: trim and convert empty strings to null const shortDescription = row.description_short ? String(row.description_short).trim() || null : null; // Process description: trim and convert empty strings to null const description = row.description ? String(row.description).trim() || null : null; return { course_code: override.newCode, title: String(row.title ?? "").trim(), short_description: shortDescription, description: description, category: override.category, level: override.level, sort_order: override.sortOrder, is_published: true, is_featured: false, course_detail_url: override.courseDetailUrl, testimonials_url: override.testimonialsUrl, }; }, upsert: async (payload, { prisma }) => { const courseDelegate = getCourseDelegate(prisma); // Check if course exists to determine if we're updating or creating const existing = await courseDelegate.findUnique({ where: { course_code: payload.course_code }, }); if (existing) { const existingTitle = (existing as { title?: string }).title; const existingShortDesc = (existing as { short_description?: string | null }).short_description; const existingDesc = (existing as { description?: string | null }).description; const hasChanges = existingTitle !== payload.title || existingShortDesc !== payload.short_description || existingDesc !== payload.description; if (hasChanges) { console.log(` πŸ”„ Updating course: ${payload.course_code}`); if (existingTitle !== payload.title) { console.log(` Title: "${existingTitle}" β†’ "${payload.title}"`); } if (existingShortDesc !== payload.short_description) { console.log( ` Short Description: ${existingShortDesc ? `"${existingShortDesc.substring(0, 50)}..."` : "null"} β†’ ${payload.short_description ? `"${payload.short_description.substring(0, 50)}..."` : "null"}`, ); } if (existingDesc !== payload.description) { console.log( ` Description: ${existingDesc ? `"${existingDesc.substring(0, 50)}..."` : "null"} β†’ ${payload.description ? `"${payload.description.substring(0, 50)}..."` : "null"}`, ); } } } else { console.log(` βž• Creating course: ${payload.course_code} (title: "${payload.title}")`); if (payload.short_description) { console.log(` Short Description: "${payload.short_description.substring(0, 50)}..."`); } if (payload.description) { console.log(` Description: "${payload.description.substring(0, 50)}..."`); } } await courseDelegate.upsert({ where: { course_code: payload.course_code }, update: { title: payload.title, short_description: payload.short_description, description: payload.description, category: payload.category, level: payload.level, sort_order: payload.sort_order, is_published: payload.is_published, is_featured: payload.is_featured, course_detail_url: payload.course_detail_url, testimonials_url: payload.testimonials_url, }, create: { course_code: payload.course_code, title: payload.title, short_description: payload.short_description, description: payload.description, category: payload.category, level: payload.level, sort_order: payload.sort_order, is_published: payload.is_published, is_featured: payload.is_featured, course_detail_url: payload.course_detail_url, testimonials_url: payload.testimonials_url, }, }); }, }

apps/recallassess/recallassess-api/src/api/admin/course-module/config/course-module-links-config.service.ts

courseModuleLinks
Type : LinkConfig[]
Default value : [getCourseModulePageLink()]

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/course-module-media.migration.ts

courseModuleMediaMigration
Type : MigrationDefinition<LegacyMediaRow, CourseModuleMediaPayload>
Default value : { name: "course-module-media", sourceQuery: ` SELECT m.media_id, m.room_name, m.record_type, m.media_type, m.file_name, m.actual_file_name, tt.topic_code FROM media m INNER JOIN training_topic tt ON m.record_id = tt.training_topic_id WHERE m.room_name = 'courseMgt_courseLevel' AND m.record_type = 'picture' ORDER BY tt.topic_code, m.media_id `, transform: async (row, context): Promise<CourseModuleMediaPayload | null> => { const topicCode = String(row.topic_code ?? "").trim(); if (!topicCode) { return null; } // Get course module ID from PostgreSQL (topic_code maps to course_module_code) const courseModuleDelegate = getCourseModuleDelegate(context.prisma); const courseModule = await courseModuleDelegate.findUnique<{ id: number }>({ where: { course_module_code: topicCode }, select: { id: true }, }); if (!courseModule) { console.warn( `⚠️ Skipping media ID ${row.media_id} for course module ${topicCode}: course module not found in PostgreSQL. Run course-modules migration first.`, ); return null; } // Filter by --only-course if specified // Since CourseModule no longer has course_id, get it from CourseModulePage if (context.onlyCourse) { // Get course_id from the first page of this module const firstPage = await context.prisma.courseModulePage.findFirst({ where: { course_module_id: courseModule.id }, select: { course_id: true }, }); if (!firstPage) { console.warn( `⚠️ Skipping media ID ${row.media_id} for course module ${topicCode}: no pages found to determine course.`, ); return null; } // Get course code from course_id const course = await context.prisma.course.findUnique({ where: { id: firstPage.course_id }, select: { course_code: true }, }); if (!course || course.course_code.toUpperCase() !== context.onlyCourse.toUpperCase()) { return null; // Skip this course module silently } } // Map room_name + record_type to MediaName enum // Only migrate records that map to COURSE_MODULE__IMAGE const mediaNameEnum = mapMediaNameEnum(row.room_name, row.record_type); if (!mediaNameEnum || mediaNameEnum !== "COURSE_MODULE__IMAGE") { console.warn( `⚠️ Skipping media ID ${row.media_id} for course module ${topicCode}: room_name='${row.room_name}', record_type='${row.record_type}' does not map to COURSE_MODULE__IMAGE`, ); return null; } // Map media_type to MediaType enum const mediaType = mapMediaType(row.media_type, row.record_type); // For legacy path: use file_name directly (format: "{media_id}_{actual_file_name}") // For new path: use actual_file_name (just the filename without media_id prefix) const legacyFileName = row.file_name; // Already has format: {media_id}_{actual_file_name} const newFileName = row.actual_file_name || row.file_name; // Just the filename // Build paths const legacyPath = buildLegacyPath(mediaType, legacyFileName); const newS3Path = buildNewS3Path(courseModule.id, row.media_id, newFileName); const mediaPath = buildMediaPath(courseModule.id, row.media_id, newFileName); return { course_module_code: topicCode, course_module_id: courseModule.id, media_id: row.media_id, legacy_path: legacyPath, new_s3_path: newS3Path, media_path: mediaPath, media_name: newFileName, media_type: mediaType, media_name_enum: mediaNameEnum, }; }, upsert: async (payload, context): Promise<void> => { const legacyBucket = optionalEnv("LEGACY_S3_BUCKET", "") || undefined; const legacyRegionOverride = optionalEnv("LEGACY_AWS_REGION", ""); const legacyAwsRegion = legacyRegionOverride !== "" ? legacyRegionOverride : optionalEnv("AWS_REGION", "ap-southeast-1"); const legacyAwsAccessKeyId = optionalEnv("LEGACY_AWS_ACCESS_KEY_ID", "") || undefined; const legacyAwsSecretAccessKey = optionalEnv("LEGACY_AWS_SECRET_ACCESS_KEY", "") || undefined; const newBucket = optionalEnv("AWS_S3_MEDIA_BUCKET", "") || undefined; const newAwsRegion = optionalEnv("AWS_REGION", "ap-southeast-1"); const newAwsAccessKeyId = optionalEnv("AWS_ACCESS_KEY_ID", "") || undefined; const newAwsSecretAccessKey = optionalEnv("AWS_SECRET_ACCESS_KEY", "") || undefined; if (!legacyBucket || !newBucket) { throw new Error("LEGACY_S3_BUCKET and AWS_S3_MEDIA_BUCKET environment variables are required"); } if (!legacyAwsAccessKeyId || !legacyAwsSecretAccessKey) { throw new Error( "LEGACY_AWS_ACCESS_KEY_ID and LEGACY_AWS_SECRET_ACCESS_KEY are required for accessing the legacy bucket", ); } if (!newAwsAccessKeyId || !newAwsSecretAccessKey) { throw new Error("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required for accessing the new bucket"); } // Initialize S3 client for legacy bucket (legacy AWS account) const legacyS3Config: { region: string; credentials: { accessKeyId: string; secretAccessKey: string; }; } = { region: legacyAwsRegion, credentials: { accessKeyId: legacyAwsAccessKeyId, secretAccessKey: legacyAwsSecretAccessKey, }, }; const legacyS3Client = new S3Client(legacyS3Config); // Initialize S3 client for new bucket (new AWS account) const newS3Config: { region: string; credentials: { accessKeyId: string; secretAccessKey: string; }; } = { region: newAwsRegion, credentials: { accessKeyId: newAwsAccessKeyId, secretAccessKey: newAwsSecretAccessKey, }, }; const newS3Client = new S3Client(newS3Config); const sourceKey = payload.legacy_path; const destinationKey = payload.new_s3_path; // Full S3 path including private/ prefix // Build full URLs for logging const legacyFullUrl = `https://${legacyBucket}.s3.${legacyAwsRegion}.amazonaws.com/${sourceKey}`; const newFullUrl = `https://${newBucket}.s3.${newAwsRegion}.amazonaws.com/${destinationKey}`; try { // Check if file already exists in new bucket let fileExists = false; try { await newS3Client.send( new HeadObjectCommand({ Bucket: newBucket, Key: destinationKey, }), ); fileExists = true; } catch (headError: unknown) { const error = headError as { name?: string }; if (error.name !== "NotFound") { throw headError; } } // Copy file if it doesn't exist // Since buckets are in different AWS accounts, we need to download from legacy bucket // and upload to new bucket (CopyObject only works within same account or with cross-account permissions) if (!fileExists) { if (context.dryRun) { console.log( `[DRY-RUN] Would copy:\n From: ${legacyFullUrl}\n To: ${newFullUrl}\n S3: s3://${legacyBucket}/${sourceKey} β†’ s3://${newBucket}/${destinationKey}`, ); } else { // Download from legacy bucket const getObjectResponse = await legacyS3Client.send( new GetObjectCommand({ Bucket: legacyBucket, Key: sourceKey, }), ); // Convert stream to buffer const chunks: Uint8Array[] = []; if (getObjectResponse.Body) { for await (const chunk of getObjectResponse.Body as AsyncIterable<Uint8Array>) { chunks.push(chunk); } } const fileBuffer = Buffer.concat(chunks); // Upload to new bucket await newS3Client.send( new PutObjectCommand({ Bucket: newBucket, Key: destinationKey, Body: fileBuffer, ContentType: getObjectResponse.ContentType, Metadata: getObjectResponse.Metadata, }), ); console.log( `βœ“ Course Module ${payload.course_module_code} (Media ID ${payload.media_id}): Copied\n From: ${legacyFullUrl}\n To: ${newFullUrl}\n S3: s3://${legacyBucket}/${sourceKey} β†’ s3://${newBucket}/${destinationKey}`, ); } } else { if (!context.dryRun) { console.log( `βœ“ Course Module ${payload.course_module_code} (Media ID ${payload.media_id}): File already exists\n URL: ${newFullUrl}\n S3: s3://${newBucket}/${destinationKey}`, ); } } // Create or update Media record in PostgreSQL if (!context.dryRun) { // Check if media record already exists for this course module and path const existingMedia = await context.prisma.media.findFirst({ where: { course_module_id: payload.course_module_id, media_path: payload.media_path, // Use media_path (without private/ prefix) }, }); if (existingMedia) { // Update existing record await context.prisma.media.update({ where: { id: existingMedia.id }, data: { media_name: payload.media_name_enum, media_type: payload.media_type, }, }); } else { // Create new record await context.prisma.media.create({ data: { media_path: payload.media_path, // Store path without private/ prefix media_name: payload.media_name_enum, media_type: payload.media_type, course_module_id: payload.course_module_id, module: "course_module", // lowercase as per schema is_public: false, // Course module media is private (in private/ folder) }, }); } } } catch (error: unknown) { const err = error as { message?: string; name?: string; code?: string; $metadata?: { httpStatusCode?: number; requestId?: string }; stack?: string; }; // Extract error details const errorMessage = err.message || err.name || err.code || String(error); const errorCode = err.code || err.name || "UnknownError"; const httpStatusCode = err.$metadata?.httpStatusCode; const requestId = err.$metadata?.requestId; console.error( `❌ Course Module ${payload.course_module_code} (Media ID ${payload.media_id}): Failed to copy\n URL: ${legacyFullUrl}\n S3: s3://${legacyBucket}/${sourceKey}\n Error: ${errorMessage}\n Code: ${errorCode}${httpStatusCode ? `\n HTTP Status: ${httpStatusCode}` : ""}${requestId ? `\n Request ID: ${requestId}` : ""}`, ); // Log full error for debugging if it's not a known error type if (!err.name && !err.code && error instanceof Error) { console.error(` Full error details:`, error); } if (err.name === "AccessDenied" || err.code === "AccessDenied" || httpStatusCode === 403) { console.error(` Access denied. Check IAM permissions for buckets: ${legacyBucket} and ${newBucket}`); throw error; } if (err.name === "NoSuchKey" || err.code === "NoSuchKey" || httpStatusCode === 404) { console.warn( ` File does not exist in source bucket:\n URL: ${legacyFullUrl}\n S3: s3://${legacyBucket}/${sourceKey}`, ); // Continue with other files - don't create DB record if file doesn't exist return; } // Continue with other files for other errors } }, }

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/course-module.migration.ts

courseModuleMigration
Type : MigrationDefinition<LegacyCourseModuleRow, CourseModulePayload>
Default value : { name: "course-module", sourceQuery: ` SELECT tt.topic_code, tt.title FROM training_topic tt WHERE tt.topic_code IN (${TOPIC_CODES_SQL_LIST}) ORDER BY FIELD(tt.topic_code, ${TOPIC_CODES_SQL_LIST}) `, transform: async (row, context): Promise<CourseModulePayload | null> => { const topicCode = String(row.topic_code ?? "").trim(); foundTopicCodes.add(topicCode); // CourseModule no longer has course_id - course relationship is established through CourseModulePage // No need to validate courses here, course mapping happens when creating CourseModulePage entries // In dry run mode, this will return the payload but won't actually create/update modules return { course_module_code: topicCode, title: String(row.title ?? "").trim(), is_published: true, sort_order: getSortOrderForTopicCode(topicCode), exclude_from_bat: getExcludeFromBat(topicCode), }; }, upsert: async (payload, context) => { const courseModuleDelegate = getCourseModuleDelegate(context.prisma); // CourseModule no longer has course_id - course relationship is through CourseModulePage // In dry run mode, skip actual database operations if (context.dryRun) { return; } await courseModuleDelegate.upsert({ where: { course_module_code: payload.course_module_code }, update: { title: payload.title, is_published: payload.is_published, sort_order: payload.sort_order, exclude_from_bat: payload.exclude_from_bat ?? undefined, }, create: { course_module_code: payload.course_module_code, title: payload.title, is_published: payload.is_published, sort_order: payload.sort_order, exclude_from_bat: payload.exclude_from_bat ?? undefined, }, }); }, }
foundTopicCodes
Type : Set<string>
Default value : new Set()
TOPIC_CODES_SQL_LIST
Type : unknown
Default value : LEGACY_TOPIC_CODES_IN_ORDER.map((code) => `'${code}'`).join(", ")

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/course-module-page-media.migration.ts

courseModulePageMediaMigration
Type : MigrationDefinition<LegacyMediaRow, CourseModulePageMediaPayload>
Default value : { name: "course-module-page-media", sourceQuery: ` SELECT * FROM ( -- Media from media table (images, attachments) SELECT m.media_id, m.room_name, m.record_type, m.media_type, m.file_name, m.actual_file_name, c.course_code, cs.title as section_title, cc.course_content_id, cc.sort_order, cs.sort_order as section_sort_order, NULL as repository_id, 'media_table' as source_type FROM media m INNER JOIN course_content cc ON m.record_id = cc.course_content_id INNER JOIN course_section cs ON cc.course_section_id = cs.course_section_id INNER JOIN course c ON cs.course_id = c.course_id WHERE m.room_name = 'courseMgt_courseContentLink' AND c.course_code IN (${LEGACY_COURSE_CODES_SQL_LIST}) AND cs.title IN (${SECTION_TITLES_SQL_LIST}) AND cc.sort_order IS NOT NULL UNION ALL -- Videos from repository table (via media table) SELECT m.media_id, m.room_name, m.record_type, m.media_type, m.file_name, m.actual_file_name, c.course_code, cs.title as section_title, cc.course_content_id, cc.sort_order, cs.sort_order as section_sort_order, r.repository_id, 'repository_table' as source_type FROM media m INNER JOIN repository r ON m.record_id = r.repository_id INNER JOIN course_content cc ON r.repository_id = cc.repository_id INNER JOIN course_section cs ON cc.course_section_id = cs.course_section_id INNER JOIN course c ON cs.course_id = c.course_id WHERE m.room_name = 'general_repository' AND m.record_type = 'video' AND cc.repository_id IS NOT NULL AND c.course_code IN (${LEGACY_COURSE_CODES_SQL_LIST}) AND cs.title IN (${SECTION_TITLES_SQL_LIST}) AND cc.sort_order IS NOT NULL UNION ALL -- Video poster images (images linked to videos via same record_id/course_content_id) SELECT m_poster.media_id, m_poster.room_name, m_poster.record_type, m_poster.media_type, m_poster.file_name, m_poster.actual_file_name, c.course_code, cs.title as section_title, cc.course_content_id, cc.sort_order, cs.sort_order as section_sort_order, NULL as repository_id, 'video_poster' as source_type FROM media m_poster INNER JOIN media m_video ON m_poster.record_id = m_video.record_id INNER JOIN course_content cc ON m_poster.record_id = cc.course_content_id INNER JOIN course_section cs ON cc.course_section_id = cs.course_section_id INNER JOIN course c ON cs.course_id = c.course_id WHERE m_poster.room_name = 'courseMgt_courseContentLink' AND m_poster.media_type = 'image' AND m_poster.record_type = 'picture' AND m_video.room_name = 'courseMgt_courseContentLink' AND m_video.media_type = 'video' AND c.course_code IN (${LEGACY_COURSE_CODES_SQL_LIST}) AND cs.title IN (${SECTION_TITLES_SQL_LIST}) AND cc.sort_order IS NOT NULL ) AS combined ORDER BY combined.course_code, combined.course_content_id, combined.sort_order, combined.media_id `, transform: async (row, context): Promise<CourseModulePageMediaPayload | null> => { const legacyCode = String(row.course_code ?? "").trim(); const sectionTitle = String(row.section_title ?? "").trim(); const sortOrder = row.sort_order ? Number(row.sort_order) : null; if (!legacyCode || !sectionTitle || !sortOrder || sortOrder <= 0) { return null; } // Map room_name + record_type to MediaName enum // Check if this is a video poster image (source_type = 'video_poster') const isVideoPoster = row.source_type === "video_poster"; const mediaNameEnum = mapMediaNameEnum(row.room_name, row.record_type, isVideoPoster); if (!mediaNameEnum) { console.warn( `⚠️ Skipping media ID ${row.media_id}: room_name='${row.room_name}', record_type='${row.record_type}', source_type='${row.source_type}' does not map to a valid MediaName`, ); return null; } // Map media_type to MediaType enum const mediaType = mapMediaType(row.media_type); // Get module code from section title mapping (same logic as course-module-pages migration) const courseModuleCode = getCourseModuleCodeForSection(legacyCode, sectionTitle); if (!courseModuleCode) { console.warn( `⚠️ Skipping media ID ${row.media_id} for course ${legacyCode} (section: "${sectionTitle}"): no module mapping found`, ); return null; } const newCourseCode = getNewCourseCodeForLegacy(legacyCode); if (!newCourseCode) { console.warn(`⚠️ Skipping media ID ${row.media_id} for course ${legacyCode}: no course code mapping found`); return null; } // Filter by --only-course if specified if (context.onlyCourse && newCourseCode.toUpperCase() !== context.onlyCourse.toUpperCase()) { return null; // Skip this course silently } // Get course ID from PostgreSQL const courseDelegate = getCourseDelegate(context.prisma); const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: newCourseCode }, select: { id: true }, }); if (!course) { console.warn( `⚠️ Skipping media ID ${row.media_id} for course ${legacyCode} (${newCourseCode}): course not found in PostgreSQL. Run courses migration first.`, ); return null; } // Get course module ID const courseModuleDelegate = getCourseModuleDelegate(context.prisma); const courseModule = await courseModuleDelegate.findUnique<{ id: number }>({ where: { course_module_code: courseModuleCode }, select: { id: true }, }); if (!courseModule) { console.warn( `⚠️ Skipping media ID ${row.media_id} for course module ${courseModuleCode}: course module not found in PostgreSQL. Run course-modules migration first.`, ); return null; } // Find course module page by course_module_id + hierarchical sort_order (same logic as course-module-pages migration) // Use section_sort_order * 10 + content_sort_order for hierarchical ordering const sectionSortOrder = row.section_sort_order ? Number(row.section_sort_order) : 0; const contentSortOrder = row.sort_order ? Number(row.sort_order) : 0; const hierarchicalSortOrder = (sectionSortOrder * 10) + contentSortOrder; const prismaClient = context.prisma as unknown as { courseModulePage?: { findFirst: (args: { where: { course_module_id: number; sort_order: number }; }) => Promise<{ id: number } | null>; }; }; if (!prismaClient.courseModulePage) { throw new Error( "Prisma client is missing the `courseModulePage` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } const courseModulePage = await prismaClient.courseModulePage.findFirst({ where: { course_module_id: courseModule.id, sort_order: hierarchicalSortOrder, }, }); if (!courseModulePage) { console.warn( `⚠️ Skipping media ID ${row.media_id} for course module ${courseModuleCode}, hierarchical sort_order ${hierarchicalSortOrder} (section: ${sectionSortOrder}, content: ${contentSortOrder}): course module page not found in PostgreSQL. Run course-module-pages migration first.`, ); return null; } // For legacy path: use file_name directly (format: "{media_id}_{actual_file_name}") // For new path: use actual_file_name (just the filename without media_id prefix) // For repository videos, file_name might already be the full path or just the filename let legacyFileName: string; let newFileName: string; if (row.source_type === "repository_table") { // Repository videos: file_name might be the full path or just filename // If it contains a path, use it directly; otherwise construct the path legacyFileName = row.file_name; newFileName = row.actual_file_name || row.file_name; } else { // Media table: file_name has format {media_id}_{actual_file_name} legacyFileName = row.file_name; newFileName = row.actual_file_name || row.file_name; } // Build paths const legacyPath = buildLegacyPath(mediaType, legacyFileName, row.source_type); const newS3Path = buildNewS3Path(courseModulePage.id, row.media_id, newFileName, mediaNameEnum); const mediaPath = buildMediaPath(courseModulePage.id, row.media_id, newFileName, mediaNameEnum); return { course_code: newCourseCode, course_module_page_id: courseModulePage.id, media_id: row.media_id, legacy_path: legacyPath, new_s3_path: newS3Path, media_path: mediaPath, media_name: newFileName, media_type: mediaType, media_name_enum: mediaNameEnum, }; }, upsert: async (payload, context): Promise<void> => { const legacyBucket = optionalEnv("LEGACY_S3_BUCKET", "") || undefined; const legacyRegionOverride = optionalEnv("LEGACY_AWS_REGION", ""); const legacyAwsRegion = legacyRegionOverride !== "" ? legacyRegionOverride : optionalEnv("AWS_REGION", "ap-southeast-1"); const legacyAwsAccessKeyId = optionalEnv("LEGACY_AWS_ACCESS_KEY_ID", "") || undefined; const legacyAwsSecretAccessKey = optionalEnv("LEGACY_AWS_SECRET_ACCESS_KEY", "") || undefined; const newBucket = optionalEnv("AWS_S3_MEDIA_BUCKET", "") || undefined; const newAwsRegion = optionalEnv("AWS_REGION", "ap-southeast-1"); const newAwsAccessKeyId = optionalEnv("AWS_ACCESS_KEY_ID", "") || undefined; const newAwsSecretAccessKey = optionalEnv("AWS_SECRET_ACCESS_KEY", "") || undefined; if (!legacyBucket || !newBucket) { throw new Error("LEGACY_S3_BUCKET and AWS_S3_MEDIA_BUCKET environment variables are required"); } if (!legacyAwsAccessKeyId || !legacyAwsSecretAccessKey) { throw new Error( "LEGACY_AWS_ACCESS_KEY_ID and LEGACY_AWS_SECRET_ACCESS_KEY are required for accessing the legacy bucket", ); } if (!newAwsAccessKeyId || !newAwsSecretAccessKey) { throw new Error("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required for accessing the new bucket"); } // Initialize S3 client for legacy bucket (legacy AWS account) const legacyS3Config: { region: string; credentials: { accessKeyId: string; secretAccessKey: string; }; } = { region: legacyAwsRegion, credentials: { accessKeyId: legacyAwsAccessKeyId, secretAccessKey: legacyAwsSecretAccessKey, }, }; const legacyS3Client = new S3Client(legacyS3Config); // Initialize S3 client for new bucket (new AWS account) const newS3Config: { region: string; credentials: { accessKeyId: string; secretAccessKey: string; }; } = { region: newAwsRegion, credentials: { accessKeyId: newAwsAccessKeyId, secretAccessKey: newAwsSecretAccessKey, }, }; const newS3Client = new S3Client(newS3Config); const sourceKey = payload.legacy_path; const destinationKey = payload.new_s3_path; // Full S3 path including private/ prefix try { // Check if file already exists in new bucket let fileExists = false; try { const headResponse = await newS3Client.send( new HeadObjectCommand({ Bucket: newBucket, Key: destinationKey, }), ); fileExists = true; if (!context.dryRun) { console.log( `βœ“ Course Module Page ${payload.course_module_page_id} (Media ID ${payload.media_id}): File already exists (${headResponse.ContentLength} bytes), skipping upload: ${destinationKey}`, ); } } catch (headError: unknown) { const error = headError as { name?: string; $metadata?: { httpStatusCode?: number }; message?: string }; // Check for NotFound error (404) or NoSuchKey error if (error.name === "NotFound" || error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { fileExists = false; // Only log in verbose mode to avoid spam if (context.dryRun) { console.log(`[DRY-RUN] File does not exist, would upload: ${destinationKey}`); } } else { // Log other errors - might be permission issues, but we'll still try to upload console.warn( `⚠️ Course Module Page ${payload.course_module_page_id} (Media ID ${payload.media_id}): Error checking file existence (${error.name || "Unknown"}, ${error.$metadata?.httpStatusCode || "N/A"}): ${error.message || "No message"}. Will attempt upload.`, ); fileExists = false; } } // Copy file if it doesn't exist // Since buckets are in different AWS accounts, we need to download from legacy bucket // and upload to new bucket (CopyObject only works within same account or with cross-account permissions) if (!fileExists) { if (context.dryRun) { console.log( `[DRY-RUN] Would copy: s3://${legacyBucket}/${sourceKey} β†’ s3://${newBucket}/${destinationKey}`, ); } else { // Download from legacy bucket const getObjectResponse = await legacyS3Client.send( new GetObjectCommand({ Bucket: legacyBucket, Key: sourceKey, }), ); // Convert stream to buffer const chunks: Uint8Array[] = []; if (getObjectResponse.Body) { for await (const chunk of getObjectResponse.Body as AsyncIterable<Uint8Array>) { chunks.push(chunk); } } const fileBuffer = Buffer.concat(chunks); // Upload to new bucket await newS3Client.send( new PutObjectCommand({ Bucket: newBucket, Key: destinationKey, Body: fileBuffer, ContentType: getObjectResponse.ContentType, Metadata: getObjectResponse.Metadata, }), ); console.log( `βœ“ Course Module Page ${payload.course_module_page_id} (Media ID ${payload.media_id}): Copied s3://${legacyBucket}/${sourceKey} β†’ s3://${newBucket}/${destinationKey}`, ); } } // Note: File existence logging is already done above when fileExists is true // Create or update Media record in PostgreSQL if (!context.dryRun) { // Check if media record already exists for this course module page and path // Also check by media_path alone in case media_name_enum was different const existingMedia = await (context.prisma.media as unknown as { findFirst: (args: { where: { course_module_page_id: number; media_path: string }; }) => Promise<{ id: number } | null>; }).findFirst({ where: { course_module_page_id: payload.course_module_page_id, media_path: payload.media_path, // Use media_path (without private/ prefix) }, }); if (existingMedia) { // Update existing record await context.prisma.media.update({ where: { id: existingMedia.id }, data: { media_name: payload.media_name_enum, media_type: payload.media_type, }, }); } else { // Create new record // Use upsert pattern to handle potential ID conflicts gracefully try { await (context.prisma.media as unknown as { create: (args: { data: { media_path: string; media_name: MediaName; media_type: MediaType; course_module_page_id: number; module: string; is_public: boolean; }; }) => Promise<unknown>; }).create({ data: { media_path: payload.media_path, // Store path without private/ prefix media_name: payload.media_name_enum, media_type: payload.media_type, course_module_page_id: payload.course_module_page_id, module: "course_module_page", // lowercase as per schema is_public: false, // Course module page media is private (in private/ folder) }, }); } catch (createError: unknown) { const err = createError as { code?: string; meta?: { target?: string[] } }; // If it's a unique constraint error on id, the sequence is out of sync // Try to find the record again (might have been created by another process) if (err.code === "P2002" && err.meta?.target?.includes("id")) { const retryFind = await (context.prisma.media as unknown as { findFirst: (args: { where: { course_module_page_id: number; media_path: string }; }) => Promise<{ id: number } | null>; }).findFirst({ where: { course_module_page_id: payload.course_module_page_id, media_path: payload.media_path, }, }); if (retryFind) { // Record exists, update it await context.prisma.media.update({ where: { id: retryFind.id }, data: { media_name: payload.media_name_enum, media_type: payload.media_type, }, }); } else { // Re-throw if we can't find it throw createError; } } else { // Re-throw other errors throw createError; } } } } } catch (error: unknown) { const err = error as { message?: string; name?: string; code?: string }; const errorMessage = err.message || String(error); console.error( `❌ Course Module Page ${payload.course_module_page_id} (Media ID ${payload.media_id}): Failed to copy ${sourceKey}: ${errorMessage}`, ); if (err.name === "AccessDenied" || err.code === "AccessDenied") { console.error(` Access denied. Check IAM permissions for buckets: ${legacyBucket} and ${newBucket}`); throw error; } if (err.name === "NoSuchKey" || err.code === "NoSuchKey") { console.warn(` File does not exist in source bucket: s3://${legacyBucket}/${sourceKey}`); // Continue with other files - don't create DB record if file doesn't exist return; } // Continue with other files for other errors } }, }
LEGACY_COURSE_CODES_SQL_LIST
Type : unknown
Default value : LEGACY_COURSE_CODES_WITH_CONTENT.map((code) => `'${code}'`).join(", ")
LEGACY_COURSE_CODES_WITH_CONTENT
Type : unknown
Default value : getLegacyCourseCodesWithContent()
SECTION_TITLES_SQL_LIST
Type : unknown
Default value : buildSectionTitlesSqlList()

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/course-module-page.migration.ts

courseModulePageMigration
Type : MigrationDefinition<LegacyCourseContentRow, CourseModulePagePayload>
Default value : { name: "course-module-page", sourceQuery: ` SELECT c.course_code, cs.title as section_title, cs.course_section_id, cs.sort_order as section_sort_order, cc.sort_order, cc.title, cc.caption, cc.description, cc.tips, cc.quiz_id, q.title as quiz_title, cc.time_frame FROM course_content cc INNER JOIN course_section cs ON cs.course_section_id = cc.course_section_id INNER JOIN course c ON c.course_id = cs.course_id LEFT JOIN quiz q ON q.quiz_id = cc.quiz_id WHERE c.course_code IN (${LEGACY_COURSE_CODES_SQL_LIST}) AND cs.title IN (${SECTION_TITLES_SQL_LIST}) AND cc.sort_order IS NOT NULL ORDER BY c.course_code, cs.sort_order, cs.course_section_id, cc.sort_order `, transform: async (row, context): Promise<CourseModulePagePayload | null> => { const legacyCourseCode = String(row.course_code ?? "").trim(); const sectionTitle = String(row.section_title ?? "").trim(); const sectionSortOrder = row.section_sort_order ? Number(row.section_sort_order) : null; const contentSortOrder = row.sort_order ? Number(row.sort_order) : null; if (!sectionTitle) { return null; } if (!contentSortOrder || contentSortOrder <= 0) { return null; } if (!sectionSortOrder || sectionSortOrder <= 0) { return null; } // Get module code from section title mapping const courseModuleCode = getCourseModuleCodeForSection(legacyCourseCode, sectionTitle); if (!courseModuleCode) { if (context.dryRun) { console.warn( `⚠️ Skipping page "${row.title}" (section: "${sectionTitle}") for course ${legacyCourseCode} - no module mapping found`, ); } return null; } const newCourseCode = getNewCourseCodeForLegacy(legacyCourseCode); if (!newCourseCode) { return null; } // Look up new course_id const courseDelegate = getCourseDelegate(context.prisma); const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: newCourseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping page "${row.title}" - course ${newCourseCode} not found in Postgres`); return null; } throw new Error( `Course with code ${newCourseCode} (legacy ${legacyCourseCode}) not found. Run course migration first.`, ); } // Look up course_module_id const courseModuleDelegate = getCourseModuleDelegate(context.prisma); const courseModule = await courseModuleDelegate.findUnique<{ id: number }>({ where: { course_module_code: courseModuleCode }, select: { id: true }, }); if (!courseModule) { if (context.dryRun) { console.warn(`⚠️ Skipping page "${row.title}" - course module ${courseModuleCode} not found in Postgres`); return null; } throw new Error( `Course module with code ${courseModuleCode} not found. Run course-modules migration first.`, ); } // Calculate sort order based on section order and content order within section // Use section_sort_order * 10 + content_sort_order for hierarchical ordering // This allows up to 9 content items per section (1-9 within each 10-block) const finalSortOrder = (sectionSortOrder * 10) + contentSortOrder; // Derive content_type and knowledge_review_id from quiz information (if any) const quizTitle = String(row.quiz_title ?? "").trim(); // Default values let contentType: ContentType = "MEDIA"; let knowledgeReviewId: number | null = null; if (quizTitle) { const normalizedTitle = quizTitle.toLowerCase(); if (normalizedTitle.includes("writing assignment")) { contentType = "ASSIGNMENT"; } else if (normalizedTitle.includes("knowledge review")) { contentType = "QUIZ"; } else { contentType = "MEDIA"; } // Attempt to find matching knowledge review by course_id and quiz_title const prismaClient = context.prisma as unknown as { knowledgeReview?: { findFirst: (args: { where: { course_id: number | null; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; }; const knowledgeReview = await prismaClient.knowledgeReview?.findFirst({ where: { course_id: course.id, title: quizTitle, }, select: { id: true }, }); if (knowledgeReview) { knowledgeReviewId = knowledgeReview.id; } } return { title: String(row.title ?? "").trim(), outline: row.caption ? String(row.caption).trim() || null : null, topic_objectives: row.description ? String(row.description).trim() || null : null, top_tips: row.tips ? String(row.tips).trim() || null : null, course_id: course.id, course_module_id: courseModule.id, sort_order: finalSortOrder, is_published: true, content_type: contentType, knowledge_review_id: knowledgeReviewId, time_frame: row.time_frame ? String(row.time_frame).trim() || null : null, }; }, upsert: async (payload, { prisma }) => { // Note: CourseModulePage doesn't have a unique constraint, so we'll use // a combination that should be unique: course_module_id + title // If a page already exists with this combination, we'll update it const prismaClient = prisma as unknown as { courseModulePage?: { findFirst: (args: { where: { course_module_id: number; title: string }; }) => Promise<{ id: number } | null>; update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>; create: (args: { data: Record<string, unknown> }) => Promise<unknown>; }; }; if (!prismaClient.courseModulePage) { throw new Error( "Prisma client is missing the `courseModulePage` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if page already exists by course_module_id + title const existing = await prismaClient.courseModulePage.findFirst({ where: { course_module_id: payload.course_module_id, title: payload.title, }, }); const pageData = { title: payload.title, outline: payload.outline, topic_objectives: payload.topic_objectives, top_tips: payload.top_tips, course_id: payload.course_id, course_module_id: payload.course_module_id, sort_order: payload.sort_order, is_published: payload.is_published, content_type: payload.content_type, knowledge_review_id: payload.knowledge_review_id, time_frame: payload.time_frame, }; if (existing) { // Update existing page await prismaClient.courseModulePage.update({ where: { id: existing.id }, data: pageData, }); } else { // Create new page await prismaClient.courseModulePage.create({ data: pageData, }); } }, }
LEGACY_COURSE_CODES_SQL_LIST
Type : unknown
Default value : LEGACY_COURSE_CODES_WITH_CONTENT.map((code) => `'${code}'`).join(", ")
LEGACY_COURSE_CODES_WITH_CONTENT
Type : unknown
Default value : getLegacyCourseCodesWithContent()
SECTION_TITLES_SQL_LIST
Type : unknown
Default value : buildSectionTitlesSqlList()

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/csm-online-knowledge-review.migration.ts

csmOnlineKnowledgeReviewMigration
Type : MigrationDefinition<CsmOnlineKrRow, CsmOnlineKrPayload>
Default value : { name: "csm-online-knowledge-review", sourceQuery: "__LOAD_FROM_JSON_CSM_ONLINE__", // Special marker for JSON-based migration transform: async (row, context): Promise<CsmOnlineKrPayload | null> => { // This transform is not used - we handle everything in loadCsmOnlineKrFromJson return null; }, upsert: async (payload, context): Promise<void> => { // This upsert is not used - we handle everything in loadCsmOnlineKrFromJson }, }

CSM-ONLINE Knowledge Review Migration

This migration creates a knowledge review for the CSM-ONLINE course from a JSON file. It does not use the legacy MySQL database - it reads directly from a JSON file.

Special marker query "LOAD_FROM_JSON_CSM_ONLINE" tells the migration runner to use the custom loadCsmOnlineKrFromJson function instead of MySQL.

apps/recallassess/recallassess-api/src/api/admin/dashboard/data.ts

dashboardData
Type : object
Default value : { // salesProfitAnalysis: { // labels: [ // "Mar-2023", // "Apr-2023", // "May-2023", // "Jun-2023", // "Jul-2023", // "Aug-2023", // "Sep-2023", // "Oct-2023", // ], // series: [ // { // name: "Sales", // type: "line", // data: [50000, 55000, 60000, 38000, 62000, 49000, 23000, 61000], // }, // { // name: "Profit", // type: "column", // data: [25000, 28000, 30000, 29000, 31000, 29500, 31500, 30500], // }, // ], // }, // salesPurchaseAnalysis: { // labels: [ // "Mar-2023", // "Apr-2023", // "May-2023", // "Jun-2023", // "Jul-2023", // "Aug-2023", // "Sep-2023", // "Oct-2023", // ], // series: [ // { // name: "Sales", // type: "line", // data: [42000, 47000, 52000, 35000, 59000, 46000, 21000, 58000], // }, // { // name: "Purchase", // type: "column", // data: [60000, 28000, 30000, 29000, 48000, 29500, 31500, 50000], // }, // ], // }, // stockAging: { // series: [90, 120, 313], // labels: ["0 - 3 months", "7 - 12 months", "12 months or more"], // }, taskDistribution: { overview: { "this-week": { new: 594, completed: 287, }, "last-week": { new: 526, completed: 260, }, }, labels: ["API", "Backend", "Frontend", "Issues"], series: { "this-week": [15, 20, 38, 27], "last-week": [19, 16, 42, 23], }, }, budgetDistribution: { categories: ["Concept", "Design", "Development", "Extras", "Marketing"], series: [ { name: "Budget", data: [12, 20, 28, 15, 25], }, ], }, weeklyExpenses: { amount: 17663, labels: [ `${now.minus({ days: 47 }).toFormat("dd MMM")} - ${now.minus({ days: 40 }).toFormat("dd MMM")}`, `${now.minus({ days: 39 }).toFormat("dd MMM")} - ${now.minus({ days: 32 }).toFormat("dd MMM")}`, `${now.minus({ days: 31 }).toFormat("dd MMM")} - ${now.minus({ days: 24 }).toFormat("dd MMM")}`, `${now.minus({ days: 23 }).toFormat("dd MMM")} - ${now.minus({ days: 16 }).toFormat("dd MMM")}`, `${now.minus({ days: 15 }).toFormat("dd MMM")} - ${now.minus({ days: 8 }).toFormat("dd MMM")}`, `${now.minus({ days: 7 }).toFormat("dd MMM")} - ${now.toFormat("dd MMM")}`, ], series: [ { name: "Expenses", data: [4412, 4345, 4541, 4677, 4322, 4123], }, ], }, monthlyExpenses: { amount: 54663, labels: [ `${now.minus({ days: 31 }).toFormat("dd MMM")} - ${now.minus({ days: 24 }).toFormat("dd MMM")}`, `${now.minus({ days: 23 }).toFormat("dd MMM")} - ${now.minus({ days: 16 }).toFormat("dd MMM")}`, `${now.minus({ days: 15 }).toFormat("dd MMM")} - ${now.minus({ days: 8 }).toFormat("dd MMM")}`, `${now.minus({ days: 7 }).toFormat("dd MMM")} - ${now.toFormat("dd MMM")}`, ], series: [ { name: "Expenses", data: [15521, 15519, 15522, 15521], }, ], }, yearlyExpenses: { amount: 648813, labels: [ `${now.minus({ days: 79 }).toFormat("dd MMM")} - ${now.minus({ days: 72 }).toFormat("dd MMM")}`, `${now.minus({ days: 71 }).toFormat("dd MMM")} - ${now.minus({ days: 64 }).toFormat("dd MMM")}`, `${now.minus({ days: 63 }).toFormat("dd MMM")} - ${now.minus({ days: 56 }).toFormat("dd MMM")}`, `${now.minus({ days: 55 }).toFormat("dd MMM")} - ${now.minus({ days: 48 }).toFormat("dd MMM")}`, `${now.minus({ days: 47 }).toFormat("dd MMM")} - ${now.minus({ days: 40 }).toFormat("dd MMM")}`, `${now.minus({ days: 39 }).toFormat("dd MMM")} - ${now.minus({ days: 32 }).toFormat("dd MMM")}`, `${now.minus({ days: 31 }).toFormat("dd MMM")} - ${now.minus({ days: 24 }).toFormat("dd MMM")}`, `${now.minus({ days: 23 }).toFormat("dd MMM")} - ${now.minus({ days: 16 }).toFormat("dd MMM")}`, `${now.minus({ days: 15 }).toFormat("dd MMM")} - ${now.minus({ days: 8 }).toFormat("dd MMM")}`, `${now.minus({ days: 7 }).toFormat("dd MMM")} - ${now.toFormat("dd MMM")}`, ], series: [ { name: "Expenses", data: [45891, 45801, 45834, 45843, 45800, 45900, 45814, 45856, 45910, 45849], }, ], }, budgetDetails: { columns: ["type", "total", "expensesAmount", "expensesPercentage", "remainingAmount", "remainingPercentage"], rows: [ { id: 1, type: "Concept", total: 14880, expensesAmount: 14000, expensesPercentage: 94.08, remainingAmount: 880, remainingPercentage: 5.92, }, { id: 2, type: "Design", total: 21080, expensesAmount: 17240.34, expensesPercentage: 81.78, remainingAmount: 3839.66, remainingPercentage: 18.22, }, { id: 3, type: "Development", total: 34720, expensesAmount: 3518, expensesPercentage: 10.13, remainingAmount: 31202, remainingPercentage: 89.87, }, { id: 4, type: "Extras", total: 18600, expensesAmount: 0, expensesPercentage: 0, remainingAmount: 18600, remainingPercentage: 100, }, { id: 5, type: "Marketing", total: 34720, expensesAmount: 19859.84, expensesPercentage: 57.2, remainingAmount: 14860.16, remainingPercentage: 42.8, }, ], }, teamMembers: [ { id: "2bfa2be5-7688-48d5-b5ac-dc0d9ac97f14", avatar: "assets/images/avatars/female-10.jpg", name: "Nadia Mcknight", email: "nadiamcknight@mail.com", phone: "+1-943-511-2203", title: "Project Director", }, { id: "77a4383b-b5a5-4943-bc46-04c3431d1566", avatar: "assets/images/avatars/male-19.jpg", name: "Best Blackburn", email: "blackburn.best@beadzza.me", phone: "+1-814-498-3701", title: "Senior Developer", }, { id: "8bb0f597-673a-47ca-8c77-2f83219cb9af", avatar: "assets/images/avatars/male-14.jpg", name: "Duncan Carver", email: "duncancarver@mail.info", phone: "+1-968-547-2111", title: "Senior Developer", }, { id: "c318e31f-1d74-49c5-8dae-2bc5805e2fdb", avatar: "assets/images/avatars/male-01.jpg", name: "Martin Richards", email: "martinrichards@mail.biz", phone: "+1-902-500-2668", title: "Junior Developer", }, { id: "0a8bc517-631a-4a93-aacc-000fa2e8294c", avatar: "assets/images/avatars/female-20.jpg", name: "Candice Munoz", email: "candicemunoz@mail.co.uk", phone: "+1-838-562-2769", title: "Lead Designer", }, { id: "a4c9945a-757b-40b0-8942-d20e0543cabd", avatar: "assets/images/avatars/female-01.jpg", name: "Vickie Mosley", email: "vickiemosley@mail.net", phone: "+1-939-555-3054", title: "Designer", }, { id: "b8258ccf-48b5-46a2-9c95-e0bd7580c645", avatar: "assets/images/avatars/female-02.jpg", name: "Tina Harris", email: "tinaharris@mail.ca", phone: "+1-933-464-2431", title: "Designer", }, { id: "f004ea79-98fc-436c-9ba5-6cfe32fe583d", avatar: "assets/images/avatars/male-02.jpg", name: "Holt Manning", email: "holtmanning@mail.org", phone: "+1-822-531-2600", title: "Marketing Manager", }, { id: "8b69fe2d-d7cc-4a3d-983d-559173e37d37", avatar: "assets/images/avatars/female-03.jpg", name: "Misty Ramsey", email: "mistyramsey@mail.us", phone: "+1-990-457-2106", title: "Consultant", }, ], summary: null as any, topCustomersAndSuppliers: null as any, stockAging: null as any, salesProfitAnalysis: null as any, salesPurchaseAnalysis: null as any, }
now
Type : unknown
Default value : DateTime.now()

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations.ts

dataMigrations
Type : MigrationDefinition[]
Default value : [ courseMigration as MigrationDefinition, courseModuleMigration as MigrationDefinition, courseModulePageMigration as MigrationDefinition, assessmentMigration as MigrationDefinition, assessmentQuestionMigration as MigrationDefinition, assessmentAnswerMigration as MigrationDefinition, knowledgeReviewMigration as MigrationDefinition, knowledgeReviewQuestionMigration as MigrationDefinition, knowledgeReviewAnswerMigration as MigrationDefinition, dnpOnlineKnowledgeReviewMigration as MigrationDefinition, csmOnlineKnowledgeReviewMigration as MigrationDefinition, pipOnlineKnowledgeReviewMigration as MigrationDefinition, packageMigration as MigrationDefinition, valuelistMigration as MigrationDefinition, emailTemplateMigration as MigrationDefinition, ]

Data migrations - Fast database operations only These migrations copy data from legacy MySQL to PostgreSQL

mediaMigrations
Type : MigrationDefinition[]
Default value : [ courseMediaMigration as MigrationDefinition, courseModuleMediaMigration as MigrationDefinition, courseModulePageMediaMigration as MigrationDefinition, ]

Media migrations - Slow S3 file copying operations These migrations copy media files from legacy S3 bucket to new bucket

migrations
Type : MigrationDefinition[]
Default value : [...dataMigrations, ...mediaMigrations]

All migrations combined (for backward compatibility)

apps/recallassess/recallassess-api/src/api/admin/report/utils/report-date-params.util.ts

DATE_ONLY_RE
Type : unknown
Default value : /^\d{4}-\d{2}-\d{2}$/

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrate-lms-data.ts

DEFAULT_BATCH_SIZE
Type : number
Default value : 1000

apps/recallassess/recallassess-api/src/api/shared/dynamic-mail-fields/dynamic-mail-fields.service.ts

DEFAULTS
Type : unknown
Default value : { COMPANY_NAME: "Recall", DOMAIN_NAME: "recallsolutions.ai", FOOTER_TEXT: "Development Through Action and Reflection", CONTACT_EMAIL: "enquiries@recallsolutions.ai", } as const

Default values when SystemSetting is not configured

DYNAMIC_MAIL_SETTING_KEYS
Type : unknown
Default value : { COMPANY_NAME: "mail.company_name", DOMAIN_NAME: "mail.domain_name", FOOTER_TEXT: "mail.footer_text", CONTACT_EMAIL: "mail.contact_email", } as const

SystemSetting keys for dynamic mail fields (configurable via Admin Settings)

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/dnp-online-knowledge-review.migration.ts

dnpOnlineKnowledgeReviewMigration
Type : MigrationDefinition<DnpOnlineKrRow, DnpOnlineKrPayload>
Default value : { name: "dnp-online-knowledge-review", sourceQuery: "__LOAD_FROM_JSON_DNP_ONLINE__", // Special marker for JSON-based migration transform: async (row, context): Promise<DnpOnlineKrPayload | null> => { // This transform is not used - we handle everything in loadDnpOnlineKrFromJson return null; }, upsert: async (payload, context): Promise<void> => { // This upsert is not used - we handle everything in loadDnpOnlineKrFromJson }, }

DNP-ONLINE Knowledge Review Migration

This migration creates a knowledge review for the DNP-ONLINE course from a JSON file. It does not use the legacy MySQL database - it reads directly from a JSON file.

Special marker query "LOAD_FROM_JSON_DNP_ONLINE" tells the migration runner to use the custom loadDnpOnlineKrFromJson function instead of MySQL.

apps/recallassess/recallassess-api/src/api/admin/report/reports/participant-progress/participant-progress-filters.service.ts

ELEARNING_STATUS_LABELS
Type : Record<ELearningProgressStatus, string>
Default value : { [ELearningProgressStatus.NOT_STARTED]: "Not started", [ELearningProgressStatus.IN_PROGRESS]: "In progress", [ELearningProgressStatus.COMPLETED]: "Completed", }
ENROLLMENT_STATUS_LABELS
Type : Record<ParticipantLearningProgressStatus, string>
Default value : { [ParticipantLearningProgressStatus.PENDING_INVITE]: "Pending invite", [ParticipantLearningProgressStatus.INVITED]: "Invited", [ParticipantLearningProgressStatus.ACCEPTED]: "Accepted", [ParticipantLearningProgressStatus.PRE_BAT]: "Pre-assessment (BAT)", [ParticipantLearningProgressStatus.E_LEARNING]: "E-learning", [ParticipantLearningProgressStatus.POST_BAT]: "Post-assessment (BAT)", [ParticipantLearningProgressStatus.COMPLETED]: "Completed", }

apps/recallassess/recallassess-api/src/api/shared/email-category/email-category.util.ts

EMAIL_CATEGORY_CONFIG
Type : EmailCategoryConfig[]
Default value : [ { category: EmailCategory.DIGEST, label: "Digest Emails", icon: "pi pi-envelope", description: "Daily, weekly, and monthly digest emails", templateKeys: [ "digest.daily", "digest.weekly", "admin.digest.weekly", ], }, { category: EmailCategory.COURSE, label: "Course Emails", icon: "pi pi-book", description: "Course-related emails including BAT, eLearning, and assignments", templateKeys: [ "course.learning.group.invitation", "course.pre.bat.completion", "course.elearning.completion", "course.knowledge.review", "course.knowledge.review.completion", "course.completion", "course.hundred.day.journey.email1", "course.hundred.day.journey.email2", "course.hundred.day.journey.email3", "course.hundred.day.journey.email4", "admin.flash.course.completed.user", "admin.flash.course.completed", "admin.flash.pre.bat.completed", "admin.flash.elearning.completed", "admin.flash.post.bat.completed", ], templateTypes: ["COURSE_ASSIGNMENT", "KNOWLEDGE_REVIEW"], }, { category: EmailCategory.REMINDERS, label: "Activity Reminders", icon: "pi pi-bell", description: "Reminders for inactivity, progress, deadlines, and assessments", templateKeys: [ "reminder.inactivity.day3", "reminder.inactivity.day7", "reminder.inactivity.day14", "reminder.inactivity.day30", "reminder.course.stuck", "reminder.pre.bat.due", "reminder.pre.bat.overdue", "reminder.post.bat.available", "reminder.post.bat", "reminder.course.deadline.7days", "reminder.course.deadline.3days", "reminder.course.deadline.1day", "admin.inactivity.weekly", "admin.inactivity.alert.day7", "admin.inactivity.alert.day14", "admin.inactivity.alert.day30", ], templateTypes: ["INACTIVITY_ALERT"], }, { category: EmailCategory.SYSTEM, label: "System Emails", icon: "pi pi-cog", description: "System notifications, account management, and security emails", templateKeys: [ "account.welcome.email", "account.password.reset", "account.email.verification", "subscription.unsubscribe.confirmation", "subscription.resubscribe.welcome", ], templateTypes: [ "SYSTEM_NOTIFICATION", "WELCOME_EMAIL", "PASSWORD_RESET", "EMAIL_VERIFICATION", "ACCOUNT_ACTIVATED", ], }, { category: EmailCategory.OTHER, label: "Other Emails", icon: "pi pi-ellipsis-h", description: "Reports, replacements, and custom emails", templateKeys: [ "admin.license.fully.allocated", "admin.license.warning", "admin.license.critical", ], templateTypes: ["REPORT_DELIVERY", "PARTICIPANT_REPLACEMENT"], }, ]

apps/recallassess/recallassess-api/src/api/shared/services/system-log-http500-notification.service.ts

EMAIL_ERROR_NOTIFICATION_RECIPIENTS_KEY
Type : string
Default value : "email.errorNotificationRecipients"

System Setting key β€” comma/semicolon/whitespace-separated admin emails for HTTP 500 system_log alerts.

ERROR_MESSAGE_MAX_LEN
Type : number
Default value : 4000
USER_AGENT_MAX_LEN
Type : number
Default value : 200

apps/recallassess/recallassess-api/src/config/email-reminder.config.ts

EMAIL_REMINDER_CONFIG
Type : unknown
Default value : { /** * Participant Inactivity Reminders */ participantInactivity: { toParticipant: { day3: { days: 3, time: "14:00", templateKey: "reminder.inactivity.day3", timezoneType: "participant" as const, enabled: false, // DISABLED: Stop sending inactivity emails to participants }, day7: { days: 7, time: "14:00", templateKey: "reminder.inactivity.day7", timezoneType: "participant" as const, enabled: false, // DISABLED: Stop sending inactivity emails to participants }, day14: { days: 14, time: "14:00", templateKey: "reminder.inactivity.day14", timezoneType: "participant" as const, enabled: false, // DISABLED: Stop sending inactivity emails to participants }, day30: { days: 30, time: "14:00", templateKey: "reminder.inactivity.day30", timezoneType: "participant" as const, enabled: false, // DISABLED: Stop sending inactivity emails to participants }, }, toAdmin: { day7: { days: 7, time: "09:00", templateKey: "admin.inactivity.alert.day7", timezoneType: "company" as const, enabled: false, // DISABLED: Stop sending inactivity alerts to admins }, day14: { days: 14, time: "09:00", templateKey: "ADMIN_INACTIVITY_ALERT_DAY14", timezoneType: "company" as const, enabled: false, // DISABLED: Stop sending inactivity alerts to admins }, day30: { days: 30, time: "09:00", templateKey: "admin.inactivity.alert.day30", timezoneType: "company" as const, enabled: false, // DISABLED: Stop sending inactivity alerts to admins }, weeklySummary: { dayOfWeek: "monday" as const, time: "09:00", templateKey: "admin.inactivity.weekly", timezoneType: "company" as const, enabled: true, }, }, }, /** * Course Progress Reminders */ courseProgress: { stuck: { daysStuck: 5, time: "10:00", templateKey: "reminder.course.stuck", timezoneType: "participant" as const, enabled: true, }, milestones: { "50%": { templateKey: "COURSE_MILESTONE_50", timezoneType: "participant" as const, enabled: true, }, "75%": { templateKey: "COURSE_MILESTONE_75", timezoneType: "participant" as const, enabled: true, }, "90%": { templateKey: "COURSE_MILESTONE_90", timezoneType: "participant" as const, enabled: true, }, }, toAdmin: { stuckAlert: { daysStuck: 5, time: "09:00", templateKey: "ADMIN_COURSE_STUCK_ALERT", timezoneType: "company" as const, enabled: true, }, }, }, /** * Assessment Reminders */ assessments: { preBat: { due: { daysBefore: 1, time: "10:00", templateKey: "reminder.pre.bat.due", timezoneType: "participant" as const, enabled: true, }, overdue: { daysAfter: 1, time: "10:00", templateKey: "reminder.pre.bat.overdue", timezoneType: "participant" as const, enabled: true, }, }, postBat: { available: { daysAfter: 2, // ~48–72h after knowledge review completion time: "10:00", templateKey: "reminder.post.bat.available", timezoneType: "participant" as const, enabled: true, }, reminder: { daysAfter: 3, time: "10:00", templateKey: "reminder.post.bat", timezoneType: "participant" as const, enabled: true, }, }, toAdmin: { pending: { time: "09:00", templateKey: "ADMIN_ASSESSMENT_PENDING", timezoneType: "company" as const, enabled: true, }, overdue: { daysAfter: 1, time: "09:00", templateKey: "ADMIN_ASSESSMENT_OVERDUE", timezoneType: "company" as const, enabled: true, }, }, }, /** * Deadline Reminders */ deadlines: { courseDeadline: { "7days": { daysBefore: 7, time: "09:00", templateKey: "reminder.course.deadline.7days", timezoneType: "participant" as const, enabled: true, }, "3days": { daysBefore: 3, time: "09:00", templateKey: "reminder.course.deadline.3days", timezoneType: "participant" as const, enabled: true, }, "1day": { daysBefore: 1, time: "09:00", templateKey: "reminder.course.deadline.1day", timezoneType: "participant" as const, enabled: true, }, }, toAdmin: { upcoming: { daysBefore: 7, time: "09:00", templateKey: "ADMIN_DEADLINE_WARNING", timezoneType: "company" as const, enabled: true, }, }, }, /** * License and Subscription Alerts */ licenses: { toAdmin: { warning: { threshold: 80, // percentage time: "09:00", templateKey: "admin.license.warning", timezoneType: "company" as const, enabled: true, }, critical: { threshold: 95, // percentage time: "09:00", templateKey: "admin.license.critical", timezoneType: "company" as const, enabled: true, }, fullyAllocated: { threshold: 100, // percentage time: "09:00", templateKey: "admin.license.fully.allocated", timezoneType: "company" as const, enabled: true, }, }, }, /** * Admin Summary Emails */ adminSummaries: { weekly: { dayOfWeek: "monday" as const, time: "09:00", templateKey: "admin.digest.weekly", timezoneType: "company" as const, enabled: true, }, monthly: { dayOfMonth: 1, time: "09:00", templateKey: "ADMIN_MONTHLY_REPORT", timezoneType: "company" as const, enabled: true, }, }, /** * Completion Notifications */ completions: { toParticipant: { courseCompleted: { templateKey: "COURSE_COMPLETION_CONGRATULATIONS", timezoneType: "participant" as const, enabled: true, }, assessmentPassed: { templateKey: "ASSESSMENT_PASSED_CONGRATULATIONS", timezoneType: "participant" as const, enabled: true, }, }, toAdmin: { courseCompleted: { templateKey: "ADMIN_COMPLETION_NOTIFICATION", timezoneType: "company" as const, enabled: true, }, }, }, /** * System Alerts */ systemAlerts: { toAdmin: { emailBounce: { templateKey: "ADMIN_EMAIL_BOUNCE_ALERT", timezoneType: "company" as const, enabled: true, }, invalidEmail: { templateKey: "ADMIN_INVALID_EMAIL_ALERT", timezoneType: "company" as const, enabled: true, }, failedEmail: { templateKey: "ADMIN_FAILED_EMAIL_ALERT", timezoneType: "company" as const, enabled: true, }, }, }, } as const

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/email-template.migration.ts

emailTemplateMigration
Type : MigrationDefinition<literal type, EmailTemplatePayload>
Default value : { name: "seed-data", // Uses __LOAD_FROM_SEED_FILES__ pattern to skip MySQL query (loads from filesystem instead) sourceQuery: "__LOAD_FROM_SEED_FILES__", transform: async (row): Promise<EmailTemplatePayload | null> => { // Load seed files on first call if (!seedFilesCache) { seedFilesCache = loadSeedFiles(); console.log(`πŸ“§ Loaded ${seedFilesCache.length} seed file(s) for seed-data migration`); } // MySQL returns rows with numeric indices, so we need to handle both formats // Row can be: { file_index: 0 } or { "0": 0 } or { file_index: 0, "0": 0 } const rowAny = row as Record<string, unknown>; const fileIndexValue = rowAny["file_index"]; const fileIndexNumeric = rowAny["0"]; const fileIndex = typeof fileIndexValue === "number" ? fileIndexValue : typeof fileIndexNumeric === "number" ? fileIndexNumeric : null; // Validate fileIndex if (typeof fileIndex !== "number" || fileIndex < 0 || !seedFilesCache || fileIndex >= seedFilesCache.length) { console.warn(`⚠️ Invalid fileIndex: ${fileIndex}, cache length: ${seedFilesCache?.length || 0}`); return null; // Skip if index is invalid or out of range } const seedFile = seedFilesCache[fileIndex]; // Double-check that seedFile exists if (!seedFile || !seedFile.filePath) { console.warn(`⚠️ Seed file at index ${fileIndex} is missing or invalid`); return null; } const fileName = seedFile.filePath.split("/").pop() || seedFile.filePath; console.log(`πŸ“„ Processing seed file ${fileIndex + 1}/${seedFilesCache.length}: ${fileName}`); return seedFile; }, upsert: async (payload, context): Promise<void> => { if (context.dryRun) { console.log(`[DRY-RUN] Would execute SQL from: ${payload.filePath}`); return; } try { // Split SQL content into individual statements, respecting quoted strings const statements = splitSQLStatements(payload.sqlContent); if (statements.length === 0) { console.warn(`⚠️ No SQL statements found in: ${payload.filePath}`); return; } console.log(`πŸ“ Executing ${statements.length} statement(s) from: ${payload.filePath}`); let executedCount = 0; let skippedCount = 0; for (let i = 0; i < statements.length; i++) { const statement = statements[i]; const statementPreview = statement.substring(0, 150).replace(/\s+/g, " "); console.log(` πŸ”„ Executing statement ${i + 1}/${statements.length}: ${statementPreview}...`); try { const result = await context.prisma.$executeRawUnsafe(statement); executedCount++; console.log(` βœ“ Statement ${i + 1} executed successfully (affected rows: ${result || "unknown"})`); } catch (error: unknown) { const err = error as { code?: string; message?: string; meta?: unknown }; // Ignore duplicate key errors (ON CONFLICT handles this) if (err.code === "P2002" || err.message?.includes("duplicate key") || err.message?.includes("Unique constraint")) { skippedCount++; console.log(` ⏭️ Statement ${i + 1}: Skipped (duplicate key - record already exists)`); continue; } // Re-throw other errors with more context console.error(`❌ Failed to execute statement ${i + 1} of ${statements.length} in ${payload.filePath}:`); console.error(` Error: ${err.message || String(error)}`); console.error(` Code: ${err.code || "N/A"}`); console.error(` Meta: ${JSON.stringify(err.meta || {})}`); console.error(` Statement (first 500 chars): ${statement.substring(0, 500)}...`); throw error; } } if (skippedCount > 0) { console.log(`βœ“ Executed ${executedCount} statement(s), skipped ${skippedCount} duplicate(s) from: ${payload.filePath}`); } else { console.log(`βœ“ Executed ${executedCount} statement(s) from: ${payload.filePath}`); } // Verify that records were created by checking the database if (executedCount > 0) { try { // Try to extract template_key from the SQL to verify const templateKeyMatch = payload.sqlContent.match(/'([^']+\.email\d+)'/); if (templateKeyMatch) { const templateKey = templateKeyMatch[1]; const existing = await context.prisma.emailTemplate.findUnique({ where: { template_key: templateKey }, select: { template_key: true, subject: true }, }); if (existing) { console.log(` βœ… Verified: Template '${templateKey}' exists in database`); } else { console.warn(` ⚠️ Warning: Template '${templateKey}' not found in database after execution`); } } } catch (verifyError) { console.warn(` ⚠️ Could not verify record creation: ${verifyError}`); // Don't throw - verification is optional } } } catch (error: unknown) { const err = error as { code?: string; message?: string }; console.error(`❌ Failed to execute SQL from ${payload.filePath}:`, err.message || String(error)); throw error; } }, }
SEED_FILES
Type : []
Default value : [ "002-testimonials.sql", "003-email-templates.sql", "005-learning-group-invitation-email-template.sql", "006-pre-bat-completion-email-template.sql", "007-e-learning-completion-email-template.sql", "008-course-completion-email-template.sql", "009-knowledge-review-completion-email-template.sql", "010-knowledge-review-invite-email-template.sql", "010-100dj-subscription-expired-banner-template.sql", "011-100dj-email1-template.sql", "012-100dj-email2-template.sql", "013-100dj-email3-template.sql", "014-100dj-email4-template.sql", "015-email-reminder-templates.sql", "016-email-system-improvements-templates.sql", "017-unsubscribe-resubscribe-email-templates.sql", "018-system-settings.sql", ]

Seed Data Migration

This migration seeds data from SQL files in the prisma/seeds directory. It excludes 001-generic-teams.sql as requested.

Files included:

  • 002-testimonials.sql
  • 003-email-templates.sql
  • 005-learning-group-invitation-email-template.sql
  • 006-pre-bat-completion-email-template.sql
  • 007-e-learning-completion-email-template.sql
  • 008-course-completion-email-template.sql
  • 009-knowledge-review-completion-email-template.sql
  • 010-knowledge-review-invite-email-template.sql
  • 010-100dj-subscription-expired-banner-template.sql
  • 011-100dj-email1-template.sql
  • 012-100dj-email2-template.sql
  • 013-100dj-email3-template.sql
  • 014-100dj-email4-template.sql
  • 015-email-reminder-templates.sql
  • 016-email-system-improvements-templates.sql
  • 017-unsubscribe-resubscribe-email-templates.sql
  • 018-system-settings.sql

NOTE: contact-support feature work moved to proper Prisma migrations:

  • prisma/migrations/20260501100000_add_contact_enquiry_inbound_email_columns
  • prisma/migrations/20260501100100_add_contact_support_email_templates The legacy seed files (019-contact-support-.sql, 020-contact-enquiry-.sql) are intentionally NOT registered here β€” CI runs prisma migrate deploy, which doesn't touch this loader, so the only way to apply schema/template changes on every environment is via the Prisma migrations folder.
seedFilesCache
Type : [] | null
Default value : null

apps/recallassess/recallassess-api/src/config/enrollment-cooling.config.ts

ENROLLMENT_COOLING_CONFIG
Type : unknown
Default value : { /** * Global enrollment cooling period (in days). * * Default is 30 days to align with the current journey cadence. */ ENROLLMENT_COOLING_PERIOD_DAYS: 30, } as const

Enrollment Cooling Period Configuration

Defines the minimum gap (in days) required between course enrollments for a participant. Used by CLLearningGroupService to enforce a cooling period across allocations (PRE_BAT / POST_BAT journey and related flows).

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/utils/mappings/course-to-topic.mapping.ts

EXCLUDE_FROM_BAT
Type : Record<string, boolean>
Default value : { "DNP-Conclusion": true, "PIP-Intro": true, "CCT-Intro": true, "CSM-Intro": true, }
LEGACY_TOPIC_CODES_IN_ORDER
Type : string[]
Default value : [ // DNP-ONLINE topics "TSN01", "TSN02", "TSN13", "TSN03", "DNP02", "TSN04", "TSN06", "TSN07", "TSN08", "TSN09", "TSN10", "DNP03", "DNP-Conclusion", //-------------// "DNP01", "TSN05", "HIC06", // PIP topics "PIP-Intro", "PIP11", "PIP12", "PIP13", "PIP01", "PIP02", "PIP03", "PIP04", "PIP05", "PIP06", "PIP07", "PIP08", "PIP09", "PIP10", "PIP14", // CCT topics "CCT-Intro", "CCT08", "CCT05", "CCT02", "CCT09", "CCT03", "CCT04", "CCT06", "CCT07", //-------------// "CCT01", "CCT10", // CSM topics "CSM-Intro", "CSM01", "CSM02", "CSM03", "HIC14", "HIC12", "HIC13", "HIC03", // Note: HIC06 is shared between DNP-ONLINE and CSM, already listed in DNP-ONLINE section above "CSM04", "HIC11", "CSM05", "CSM08", "CSM09", "CSM10", "CSM11", //-------------// "HIC09", "HIC01", "HIC02", "CSM06", "CSM07", ]
LEGACY_TOPIC_TO_COURSE_CODE
Type : Record<string, string | []>
Default value : { // DNP-ONLINE topics "TSN01": "DNP", "TSN02": "DNP", "TSN13": "DNP", "TSN03": "DNP", "DNP02": "DNP", "TSN04": "DNP", "TSN06": "DNP", "TSN07": "DNP", "TSN08": "DNP", "TSN09": "DNP", "TSN10": "DNP", "DNP03": "DNP", "DNP-Conclusion": "DNP", "DNP01": "DNP", "TSN05": "DNP", "HIC06": ["DNP", "CSM"], // Shared between DNP-ONLINE and CSM // PIP topics "PIP-Intro": "PIP", "PIP11": "PIP", "PIP12": "PIP", "PIP13": "PIP", "PIP01": "PIP", "PIP02": "PIP", "PIP03": "PIP", "PIP04": "PIP", "PIP05": "PIP", "PIP06": "PIP", "PIP07": "PIP", "PIP08": "PIP", "PIP09": "PIP", "PIP10": "PIP", "PIP14": "PIP", // CCT topics "CCT-Intro": "CCT", "CCT08": "CCT", "CCT05": "CCT", "CCT02": "CCT", "CCT09": "CCT", "CCT03": "CCT", "CCT04": "CCT", "CCT06": "CCT", "CCT07": "CCT", "CCT01": "CCT", "CCT10": "CCT", // CSM topics "CSM-Intro": "CSM", "CSM01": "CSM", "CSM02": "CSM", "CSM03": "CSM", "HIC14": "CSM", "HIC12": "CSM", "HIC13": "CSM", "HIC03": "CSM", // Note: HIC06 is shared between DNP-ONLINE and CSM (see mapping above) "CSM04": "CSM", "HIC11": "CSM", "CSM05": "CSM", "CSM08": "CSM", "CSM09": "CSM", "CSM10": "CSM", "CSM11": "CSM", "HIC09": "CSM", "HIC01": "CSM", "HIC02": "CSM", "CSM06": "CSM", "CSM07": "CSM", }

apps/recallassess/recallassess-api/src/config/hundred-dj.config.ts

HUNDRED_DJ_EMAIL_CONFIG
Type : unknown
Default value : { /** * Number of days after e-learning completion to send Email 1 */ EMAIL_1_DAYS: 2, /** * Number of days after e-learning completion to send Email 2 */ EMAIL_2_DAYS: 7, /** * Number of days after e-learning completion to send Email 3 */ EMAIL_3_DAYS: 14, /** * Number of days after e-learning completion to send Email 4 */ EMAIL_4_DAYS: 30, /** * Number of days after e-learning completion to send Knowledge Review Email * This should be AFTER all 100DJ emails (Email 4 is at 53 days) */ KNOWLEDGE_REVIEW_EMAIL_DAYS: 60, } as const

100-Day Journey (100DJ) Email Configuration

This configuration defines the spacing between 100DJ emails using a cumulative approach:

  • Email 1: 2 days after E-Learning completion
  • Email 2: 7 days after Email 1 (total: 9 days)
  • Email 3: 14 days after Email 2 (total: 23 days)
  • Email 4: 30 days after Email 3 (total: 53 days)
  • Knowledge Review: 60 days after E-Learning completion (AFTER all 100DJ emails)

The Knowledge Review email is sent AFTER the complete 100DJ journey to assess long-term knowledge retention.

Future enhancement: These values could be moved to SystemSetting table for dynamic configuration via admin UI.

apps/recallassess/recallassess-api/src/api/shared/invoice/invoice-pdf.service.ts

INVOICE_PDF_RENDERER_VERSION
Type : number
Default value : 6

Bump when the PDF bytes layout changes (same idea as report revisions). S3 objects are also selected by LastModified + plausibility, not only this token.

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/invoice-config.ts

InvoiceConfig
Type : unknown
Default value : new ReportConfig({ name: "invoice", title: "Invoice", type: ReportType.fluid, })

Single-invoice printable report (browser/PDF); spreadsheet export is on invoice-summary.

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/invoice-summary-config.ts

InvoiceSummaryConfig
Type : unknown
Default value : new ReportConfig({ name: "invoice-summary", title: "Invoice summary", type: ReportType.fluid, hasXlsxExport: true, hasCsvExport: true, xlsxPattern: ReportXlsxPattern.Code, xlsxFileNamePrefix: "invoice-summary", xlsxSheetName: "Invoices", xlsxColumns: [ { key: "invoice_number", header: "Invoice #" }, { key: "course_name", header: "Course" }, { key: "status_label", header: "Status" }, { key: "invoice_type_label", header: "Type" }, { key: "package_type_label", header: "Package" }, { key: "license_quantity", header: "Licenses", type: ReportXlsxColumnType.Number }, { key: "unit_price_per_license", header: "Unit price", type: ReportXlsxColumnType.Number }, { key: "total_amount", header: "Total", type: ReportXlsxColumnType.Number }, { key: "paid_date", header: "Paid", type: ReportXlsxColumnType.Date }, { key: "period_start", header: "Period start", type: ReportXlsxColumnType.Date }, { key: "period_end", header: "Period end", type: ReportXlsxColumnType.Date }, { key: "created_at", header: "Created", type: ReportXlsxColumnType.Date }, ], })

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/knowledge-review-answer.migration.ts

knowledgeReviewAnswerMigration
Type : MigrationDefinition<LegacyQuizQuestionAnswerRow, KnowledgeReviewAnswerPayload>
Default value : { name: "knowledge-review-answer", sourceQuery: LEGACY_COURSE_CODES.length > 0 ? ` SELECT qqa.quiz_id, qqa.quiz_question_id, qqa.quiz_question_answer_id, c.course_code, q.title as quiz_title, qqa.answer_text, qqa.correct_answer, qqa.sort_order FROM quiz_question_answer qqa INNER JOIN quiz_question qq ON qq.quiz_question_id = qqa.quiz_question_id INNER JOIN quiz q ON q.quiz_id = qqa.quiz_id INNER JOIN course c ON c.course_id = q.course_id WHERE c.course_code IN (${LEGACY_COURSE_CODES_SQL_LIST}) ORDER BY qqa.quiz_id, qqa.quiz_question_id, qqa.sort_order, qqa.quiz_question_answer_id ` : ` SELECT qqa.quiz_id, qqa.quiz_question_id, qqa.quiz_question_answer_id, c.course_code, q.title as quiz_title, qqa.answer_text, qqa.correct_answer, qqa.sort_order FROM quiz_question_answer qqa INNER JOIN quiz_question qq ON qq.quiz_question_id = qqa.quiz_question_id INNER JOIN quiz q ON q.quiz_id = qqa.quiz_id INNER JOIN course c ON c.course_id = q.course_id WHERE 1=0 ORDER BY qqa.quiz_id, qqa.quiz_question_id, qqa.sort_order, qqa.quiz_question_answer_id `, transform: async (row, context): Promise<KnowledgeReviewAnswerPayload | null> => { const legacyCourseCode = String(row.course_code ?? "").trim(); const quizTitle = String(row.quiz_title ?? "").trim(); if (!legacyCourseCode) { if (context.dryRun) { console.warn(`⚠️ Skipping answer - no course_code found`); return null; } else { console.warn( `⚠️ Skipping answer (quiz_question_answer_id: ${row.quiz_question_answer_id}) - no course_code found`, ); return null; } } // Get the new course code for this legacy course code const newCourseCode = getNewCourseCodeForLegacyCourseCode(legacyCourseCode); if (!newCourseCode) { // This course code is not in the mapping, skip it return null; } const courseDelegate = getCourseDelegate(context.prisma); const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: newCourseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping answer - course ${newCourseCode} not found`); return null; } else { console.warn( `⚠️ Skipping answer (quiz_question_answer_id: ${row.quiz_question_answer_id}) - course ${newCourseCode} not found`, ); return null; } } // Look up knowledge review by course_id and quiz title const knowledgeReview = await ( context.prisma as unknown as { knowledgeReview?: { findFirst: (args: { where: { course_id: number | null; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).knowledgeReview?.findFirst({ where: { course_id: course.id, title: quizTitle, }, select: { id: true }, }); if (!knowledgeReview) { // Skip answer if knowledge review doesn't exist if (context.dryRun) { console.warn(`⚠️ Skipping answer - knowledge review "${quizTitle}" not found`); return null; } else { console.warn( `⚠️ Skipping answer (quiz_question_answer_id: ${row.quiz_question_answer_id}) - knowledge review "${quizTitle}" not found (likely skipped)`, ); return null; } } // Get question text from legacy quiz_question table const quizQuestionId = row.quiz_question_id ? Number(row.quiz_question_id) : null; if (!quizQuestionId) { return null; } const [questionRows] = await context.mysql.query( `SELECT question_text FROM quiz_question WHERE quiz_question_id = ?`, [quizQuestionId], ); const typedQuestionRows = questionRows as Array<{ question_text: string }>; if (!typedQuestionRows || typedQuestionRows.length === 0) { return null; } const questionText = String(typedQuestionRows[0]?.question_text ?? "").trim(); // Find knowledge review question by question text const knowledgeReviewQuestion = await ( context.prisma as unknown as { knowledgeReviewQuestion?: { findFirst: (args: { where: { knowledge_review_id: number; course_id: number; question_text: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).knowledgeReviewQuestion?.findFirst({ where: { knowledge_review_id: knowledgeReview.id, course_id: course.id, question_text: questionText, }, select: { id: true }, }); if (!knowledgeReviewQuestion) { // Skip answer if question doesn't exist (likely because it was skipped) if (context.dryRun) { console.warn( `⚠️ Skipping answer - knowledge review question not found for question: "${questionText.substring(0, 50)}..."`, ); return null; } else { console.warn( `⚠️ Skipping answer (quiz_question_answer_id: ${row.quiz_question_answer_id}) - knowledge review question not found for question: "${questionText.substring(0, 50)}..." (likely skipped)`, ); return null; } } // Convert correct_answer to boolean const isCorrect = row.correct_answer === true || row.correct_answer === 1 || row.correct_answer === "1" || row.correct_answer === "true" || false; const sortOrder = row.sort_order ? Number(row.sort_order) : 0; return { course_id: course.id, knowledge_review_id: knowledgeReview.id, knowledge_review_question_id: knowledgeReviewQuestion.id, answer_text: row.answer_text ? String(row.answer_text).trim() : null, is_correct: isCorrect, sort_order: sortOrder, }; }, upsert: async (payload, { prisma }) => { const prismaClient = prisma as unknown as { knowledgeReviewAnswer?: { findFirst: (args: { where: { knowledge_review_question_id: number; answer_text: string | null; sort_order: number; }; }) => Promise<{ id: number } | null>; update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>; create: (args: { data: Record<string, unknown> }) => Promise<unknown>; }; }; if (!prismaClient.knowledgeReviewAnswer) { throw new Error( "Prisma client is missing the `knowledgeReviewAnswer` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if answer already exists const existing = await prismaClient.knowledgeReviewAnswer.findFirst({ where: { knowledge_review_question_id: payload.knowledge_review_question_id, answer_text: payload.answer_text, sort_order: payload.sort_order, }, }); const answerData = { course_id: payload.course_id, knowledge_review_id: payload.knowledge_review_id, knowledge_review_question_id: payload.knowledge_review_question_id, answer_text: payload.answer_text, is_correct: payload.is_correct, sort_order: payload.sort_order, }; if (existing) { await prismaClient.knowledgeReviewAnswer.update({ where: { id: existing.id }, data: answerData, }); } else { await prismaClient.knowledgeReviewAnswer.create({ data: answerData, }); } }, }
LEGACY_COURSE_CODES
Type : unknown
Default value : getLegacyCourseCodesForKnowledgeReview()
LEGACY_COURSE_CODES_SQL_LIST
Type : unknown
Default value : LEGACY_COURSE_CODES.length > 0 ? LEGACY_COURSE_CODES.map((code) => `'${code.replace(/'/g, "''")}'`).join(", ") : "''"

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/knowledge-review.migration.ts

knowledgeReviewMigration
Type : MigrationDefinition<LegacyQuizRow, KnowledgeReviewPayload>
Default value : { name: "knowledge-review", sourceQuery: LEGACY_COURSE_CODES.length > 0 ? ` SELECT q.quiz_id, q.course_id, c.course_code, q.title, q.description, q.pass_percentage FROM quiz q INNER JOIN course c ON c.course_id = q.course_id WHERE c.course_code IN (${LEGACY_COURSE_CODES_SQL_LIST}) ORDER BY q.course_id, q.quiz_id ` : ` SELECT q.quiz_id, q.course_id, c.course_code, q.title, q.description, q.pass_percentage FROM quiz q INNER JOIN course c ON c.course_id = q.course_id WHERE 1=0 ORDER BY q.course_id, q.quiz_id `, transform: async (row, context): Promise<KnowledgeReviewPayload | null> => { const legacyCourseCode = String(row.course_code ?? "").trim(); const quizTitle = String(row.title ?? "").trim(); // Log all quizzes found for debugging console.log(`πŸ“‹ Processing quiz: "${quizTitle}" (quiz_id: ${row.quiz_id}) for legacy course "${legacyCourseCode}"`); if (!legacyCourseCode) { if (context.dryRun) { console.warn(`⚠️ Skipping knowledge review "${quizTitle}" - no course_code found`); return null; } else { console.warn(`⚠️ Skipping knowledge review (quiz_id: ${row.quiz_id}) - no course_code found`); return null; } } // Get the new course code for this legacy course code const newCourseCode = getNewCourseCodeForLegacyCourseCode(legacyCourseCode); if (!newCourseCode) { // This course code is not in the mapping, skip it if (context.dryRun) { console.warn(`⚠️ Skipping knowledge review "${quizTitle}" - legacy course code "${legacyCourseCode}" not in mapping`); } else { console.warn(`⚠️ Skipping knowledge review (quiz_id: ${row.quiz_id}, title: "${quizTitle}") - legacy course code "${legacyCourseCode}" not in mapping`); } return null; } console.log(` β†’ Mapped legacy course "${legacyCourseCode}" to new course code "${newCourseCode}"`); // Look up course by new course code let courseId: number | null = null; const courseDelegate = getCourseDelegate(context.prisma); const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: newCourseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping knowledge review "${quizTitle}" - course ${newCourseCode} not found (mapped from legacy code "${legacyCourseCode}")`); return null; } else { console.warn( `⚠️ Skipping knowledge review (quiz_id: ${row.quiz_id}, title: "${quizTitle}") - course ${newCourseCode} not found (mapped from legacy code "${legacyCourseCode}"). Run course migration first.`, ); return null; } } courseId = course.id; if (context.dryRun) { console.log(`βœ“ Would create knowledge review "${quizTitle}" for course ${newCourseCode} (ID: ${courseId})`); } return { course_id: courseId, course_code: newCourseCode, title: quizTitle, description: row.description ? String(row.description).trim() || null : null, pass_percentage: row.pass_percentage != null ? Number(row.pass_percentage) : null, is_published: true, }; }, upsert: async (payload, { prisma }) => { const prismaClient = prisma as unknown as { knowledgeReview?: { findFirst: (args: { where: { course_id: number | null; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; create: (args: { data: Record<string, unknown> }) => Promise<{ id: number }>; update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<{ id: number }>; }; course?: { update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>; }; }; if (!prismaClient.knowledgeReview) { throw new Error( "Prisma client is missing the `knowledgeReview` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if knowledge review already exists // Note: course_id can be null, so we need to handle that in the where clause const whereClause: { course_id: number | null; title: string } = { course_id: payload.course_id, title: payload.title, }; const existing = await prismaClient.knowledgeReview.findFirst({ where: whereClause, select: { id: true }, }); const knowledgeReviewData = { title: payload.title, description: payload.description, course_id: payload.course_id, pass_percentage: payload.pass_percentage, is_published: payload.is_published, }; let knowledgeReview: { id: number }; if (existing) { // Update existing knowledge review knowledgeReview = await prismaClient.knowledgeReview.update({ where: { id: existing.id }, data: knowledgeReviewData, }); console.log(`βœ“ Updated knowledge review: "${payload.title}" (ID: ${knowledgeReview.id}) for course ${payload.course_code}`); } else { // Create new knowledge review knowledgeReview = await prismaClient.knowledgeReview.create({ data: knowledgeReviewData, }); console.log(`βœ“ Created knowledge review: "${payload.title}" (ID: ${knowledgeReview.id}) for course ${payload.course_code}`); } // Check if this knowledge review should be set as the course's main knowledge review if (payload.course_id && isCourseKnowledgeReview(payload.course_code, payload.title)) { // Update course with knowledge_review_id if (prismaClient.course) { await prismaClient.course.update({ where: { id: payload.course_id }, data: { knowledge_review_id: knowledgeReview.id }, }); console.log(`βœ“ Set knowledge review "${payload.title}" (ID: ${knowledgeReview.id}) as main knowledge review for course ${payload.course_code}`); } } }, }

Knowledge Review Migration

Migrates quiz records from legacy MySQL to knowledge_review table in PostgreSQL. Also automatically sets course.knowledge_review_id for courses that have a main knowledge review defined in course-knowledge-review.mapping.ts

Example: "PERSUASION & INFLUENCING PSYCHOLOGY KNOWLEDGE REVIEW" is set as the main knowledge review for the PIP course.

LEGACY_COURSE_CODES
Type : unknown
Default value : getLegacyCourseCodesForKnowledgeReview()
LEGACY_COURSE_CODES_SQL_LIST
Type : unknown
Default value : LEGACY_COURSE_CODES.length > 0 ? LEGACY_COURSE_CODES.map((code) => `'${code.replace(/'/g, "''")}'`).join(", ") : "''"

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/knowledge-review-question.migration.ts

knowledgeReviewQuestionMigration
Type : MigrationDefinition<LegacyQuizQuestionRow, KnowledgeReviewQuestionPayload>
Default value : { name: "knowledge-review-question", sourceQuery: LEGACY_COURSE_CODES.length > 0 ? ` SELECT qq.quiz_id, qq.quiz_question_id, c.course_code, q.title as quiz_title, qq.question_text, qq.answer_type, qq.sort_order FROM quiz_question qq INNER JOIN quiz q ON q.quiz_id = qq.quiz_id INNER JOIN course c ON c.course_id = q.course_id WHERE c.course_code IN (${LEGACY_COURSE_CODES_SQL_LIST}) ORDER BY qq.quiz_id, qq.sort_order, qq.quiz_question_id ` : ` SELECT qq.quiz_id, qq.quiz_question_id, c.course_code, q.title as quiz_title, qq.question_text, qq.answer_type, qq.sort_order FROM quiz_question qq INNER JOIN quiz q ON q.quiz_id = qq.quiz_id INNER JOIN course c ON c.course_id = q.course_id WHERE 1=0 ORDER BY qq.quiz_id, qq.sort_order, qq.quiz_question_id `, transform: async (row, context): Promise<KnowledgeReviewQuestionPayload | null> => { const legacyCourseCode = String(row.course_code ?? "").trim(); const quizTitle = String(row.quiz_title ?? "").trim(); if (!legacyCourseCode) { if (context.dryRun) { console.warn(`⚠️ Skipping question - no course_code found`); return null; } else { console.warn(`⚠️ Skipping question (quiz_question_id: ${row.quiz_question_id}) - no course_code found`); return null; } } // Get the new course code for this legacy course code const newCourseCode = getNewCourseCodeForLegacyCourseCode(legacyCourseCode); if (!newCourseCode) { // This course code is not in the mapping, skip it return null; } const courseDelegate = getCourseDelegate(context.prisma); const course = await courseDelegate.findUnique<{ id: number }>({ where: { course_code: newCourseCode }, select: { id: true }, }); if (!course) { if (context.dryRun) { console.warn(`⚠️ Skipping question - course ${newCourseCode} not found`); return null; } else { console.warn( `⚠️ Skipping question (quiz_question_id: ${row.quiz_question_id}) - course ${newCourseCode} not found`, ); return null; } } // Look up knowledge review by course_id and quiz title const knowledgeReview = await ( context.prisma as unknown as { knowledgeReview?: { findFirst: (args: { where: { course_id: number | null; title: string }; select: { id: true }; }) => Promise<{ id: number } | null>; }; } ).knowledgeReview?.findFirst({ where: { course_id: course.id, title: quizTitle, }, select: { id: true }, }); if (!knowledgeReview) { // Skip question if knowledge review doesn't exist if (context.dryRun) { console.warn(`⚠️ Skipping question - knowledge review "${quizTitle}" not found`); return null; } else { console.warn( `⚠️ Skipping question (quiz_question_id: ${row.quiz_question_id}) - knowledge review "${quizTitle}" not found (likely skipped)`, ); return null; } } const questionText = String(row.question_text ?? "").trim(); if (!questionText) { return null; } const sortOrder = row.sort_order ? Number(row.sort_order) : 0; const questionType = mapAnswerTypeToQuestionType(row.answer_type); return { course_id: course.id, knowledge_review_id: knowledgeReview.id, question_text: questionText, question_type: questionType, sort_order: sortOrder, }; }, upsert: async (payload, { prisma }) => { const prismaClient = prisma as unknown as { knowledgeReviewQuestion?: { findFirst: (args: { where: { knowledge_review_id: number; course_id: number; question_text: string; sort_order: number; }; }) => Promise<{ id: number } | null>; update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>; create: (args: { data: Record<string, unknown> }) => Promise<unknown>; }; }; if (!prismaClient.knowledgeReviewQuestion) { throw new Error( "Prisma client is missing the `knowledgeReviewQuestion` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if question already exists const existing = await prismaClient.knowledgeReviewQuestion.findFirst({ where: { knowledge_review_id: payload.knowledge_review_id, course_id: payload.course_id, question_text: payload.question_text, sort_order: payload.sort_order, }, }); const questionData = { course_id: payload.course_id, knowledge_review_id: payload.knowledge_review_id, question_text: payload.question_text, question_type: payload.question_type, sort_order: payload.sort_order, }; if (existing) { await prismaClient.knowledgeReviewQuestion.update({ where: { id: existing.id }, data: questionData, }); } else { await prismaClient.knowledgeReviewQuestion.create({ data: questionData, }); } }, }
LEGACY_COURSE_CODES
Type : unknown
Default value : getLegacyCourseCodesForKnowledgeReview()
LEGACY_COURSE_CODES_SQL_LIST
Type : unknown
Default value : LEGACY_COURSE_CODES.length > 0 ? LEGACY_COURSE_CODES.map((code) => `'${code.replace(/'/g, "''")}'`).join(", ") : "''"

apps/recallassess/recallassess-api/src/api/client/knowledge-review/knowledge-review.service.ts

KnowledgeReviewSubmissionType
Type : object
Default value : { COURSE_LEVEL: "COURSE_LEVEL" as const, EMBEDDED_QUIZ: "EMBEDDED_QUIZ" as const, EMBEDDED_ASSIGNMENT: "EMBEDDED_ASSIGNMENT" as const, }

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/utils/mappings/legacy-to-new-course-code.mapping.ts

LEGACY_TO_NEW_CODE
Type : Record<string, string>
Default value : { "PIP-Online": "PIP", "CSM-Online": "CSM", "CCT-Online": "CCT", "DNP-Online": "DNP", }

apps/recallassess/recallassess-api/src/api/client/learning-group/events/license-allocation.events.ts

LICENSE_ALLOCATION_EVENTS
Type : unknown
Default value : { LICENSE_ALLOCATED: "license.allocated", LICENSE_RELEASED: "license.released", } as const

License Allocation Events These events are emitted when licenses are allocated or released Subscribers can listen and react to these events for immediate notifications

apps/recallassess/recallassess-api/src/api/admin/report/reports/invoice/invoice-filters.service.ts

MAX_INVOICE_ROWS
Type : number
Default value : 5000

apps/recallassess/recallassess-api/src/api/admin/report/reports/subscription/subscription-filters.service.ts

MAX_SUBSCRIPTION_PAYMENT_ROWS
Type : number
Default value : 10_000

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/utils/mappings/missing-modules.mapping.ts

MISSING_MODULE_DEFINITIONS
Type : Record<string, literal type>
Default value : { "CCT-Intro": { title: "Course Introduction", courseCode: "CCT", }, "PIP-Intro": { title: "Course Introduction", courseCode: "PIP", }, "DNP-Conclusion": { title: "Putting it all together", courseCode: "DNP", }, "CSM-Intro": { title: "Course Introduction", courseCode: "CSM", }, }

Defines modules that don't exist in the legacy training_topic table but need to be created in the new system. DNP renamed to DNP-ONLINE, old DNP-ONLINE (with -ONLINE suffix) removed

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/package.migration.ts

packageMigration
Type : MigrationDefinition<PackageData, PackagePayload>
Default value : { name: "package", // Use a special marker query that the runner will detect sourceQuery: "__LOAD_FROM_JSON__", transform: async (row): Promise<PackagePayload | null> => { // Validate required fields if (!row.package_type || !row.name) { return null; } return { package_type: row.package_type, name: row.name, description: row.description ? String(row.description).trim() || null : null, features: row.features ? String(row.features).trim() || null : null, license_count_start: row.license_count_start ?? 0, license_count_end: row.license_count_end ?? 0, minimum_license_required: row.minimum_license_required ?? 0, price_per_licence: row.price_per_licence ?? null, is_active: row.is_active ?? true, is_trial_package: row.is_trial_package ?? false, trial_duration_days: row.trial_duration_days ?? null, free_credits: row.free_credits ?? null, sort_order: row.sort_order ?? 0, flag: row.flag ?? null, }; }, upsert: async (payload, { prisma }) => { const prismaClient = prisma as unknown as { package?: { findUnique: (args: { where: { package_type: PackageType }; select: { id: true }; }) => Promise<{ id: number } | null>; create: (args: { data: Record<string, unknown> }) => Promise<{ id: number }>; update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<{ id: number }>; }; }; if (!prismaClient.package) { throw new Error( "Prisma client is missing the `package` delegate. Run `npx prisma generate --schema=apps/recallassess/recallassess-api/prisma/schema.prisma` and retry.", ); } // Check if package already exists const existing = await prismaClient.package.findUnique({ where: { package_type: payload.package_type }, select: { id: true }, }); const packageData = { package_type: payload.package_type, name: payload.name, description: payload.description, features: payload.features, license_count_start: payload.license_count_start, license_count_end: payload.license_count_end, minimum_license_required: payload.minimum_license_required, price_per_licence: payload.price_per_licence, is_active: payload.is_active, is_trial_package: payload.is_trial_package, trial_duration_days: payload.trial_duration_days, free_credits: payload.free_credits, sort_order: payload.sort_order, flag: payload.flag, }; if (existing) { // Update existing package await prismaClient.package.update({ where: { id: existing.id }, data: packageData, }); } else { // Create new package await prismaClient.package.create({ data: packageData, }); } }, }

apps/recallassess/recallassess-api/src/scripts/migrate-course-module-page-videos-to-bunny/migrate-course-module-page-videos-to-bunny.ts

pageCourseVideoWhere
Type : object
Default value : { course_module_page_id: { not: null }, media_type: MediaType.VIDEO, media_name: MediaName.COURSE_MODULE_PAGE__VIDEO, }

Main video slot on a course module page. We do not filter on media.module β€” some DBs use course_module_page, while app config uses course-module-page; the FK + media_name are enough.

apps/recallassess/recallassess-api/src/api/client/participant/events/participant.events.ts

PARTICIPANT_EVENTS
Type : unknown
Default value : { CREATED: "participant.created", STATUS_UPDATED: "participant.status.updated", DELETED: "participant.deleted", ACTIVITY: "participant.activity", REACTIVATED: "participant.reactivated", } as const

Event names as constants for type safety

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/participant-progress-config.ts

ParticipantProgressConfig
Type : unknown
Default value : new ReportConfig({ name: "participant-progress", title: "Participant Progress", type: ReportType.fluid, })

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/participant-progress-summary-config.ts

ParticipantProgressSummaryConfig
Type : unknown
Default value : new ReportConfig({ name: "participant-progress-summary", title: "Participant Progress Summary", type: ReportType.fluid, hasXlsxExport: true, hasCsvExport: true, xlsxPattern: ReportXlsxPattern.Code, xlsxFileNamePrefix: "participant-progress-summary", xlsxSheetName: "Participant Progress Summary", xlsxColumns: [ { key: "participant_name", header: "Participant" }, { key: "participant_email", header: "Email" }, { key: "course_title", header: "Course" }, { key: "learning_group_name", header: "Learning group" }, { key: "enrollment_status_label", header: "Stage" }, { key: "overall_completion_percentage", header: "Overall %", type: ReportXlsxColumnType.Percent }, { key: "pre_bat_ipq", header: "Pre-BAT IPQ", type: ReportXlsxColumnType.Number }, { key: "e_learning_status_label", header: "E-learning" }, { key: "e_learning_progress_percentage", header: "E-learn %", type: ReportXlsxColumnType.Percent }, { key: "post_bat_ipq", header: "Post-BAT IPQ", type: ReportXlsxColumnType.Number }, { key: "course_completed_at", header: "Course completed", type: ReportXlsxColumnType.Date }, ], })

apps/recallassess/recallassess-api/src/config/password.config.ts

PASSWORD_REQUIREMENTS
Type : unknown
Default value : { minLength: 8, uppercase: /[A-Z]/, number: /\d/, special: /[\W_]/, } as const

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/pip-online-knowledge-review.migration.ts

pipOnlineKnowledgeReviewMigration
Type : MigrationDefinition<PipOnlineKrRow, PipOnlineKrPayload>
Default value : { name: "pip-online-knowledge-review", sourceQuery: "__LOAD_FROM_JSON_PIP_ONLINE__", // Special marker for JSON-based migration transform: async (row, context): Promise<PipOnlineKrPayload | null> => { // This transform is not used - we handle everything in loadPipOnlineKrFromJson return null; }, upsert: async (payload, context): Promise<void> => { // This upsert is not used - we handle everything in loadPipOnlineKrFromJson }, }

PIP-ONLINE Knowledge Review Migration

This migration creates a knowledge review for the PIP course from a JSON file. It does not use the legacy MySQL database - it reads directly from a JSON file.

Special marker query "LOAD_FROM_JSON_PIP_ONLINE" tells the migration runner to use the custom loadPipOnlineKrFromJson function instead of MySQL.

apps/recallassess/recallassess-api/src/scripts/cancel-pending-emails-unsubscribed-admins.ts

possibleEnvPaths
Type : []
Default value : [ resolve(process.cwd(), "docker/.env"), // From apps/recallassess/recallassess-api/ resolve(process.cwd(), "../../docker/.env"), // From project root resolve(process.cwd(), ".env"), // From any location ]
prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/count-pending-emails.ts

possibleEnvPaths
Type : []
Default value : [ resolve(process.cwd(), "docker/.env"), resolve(process.cwd(), "../../docker/.env"), resolve(process.cwd(), ".env"), ]
prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/diagnose-pending-emails.ts

possibleEnvPaths
Type : []
Default value : [ resolve(process.cwd(), "docker/.env"), resolve(process.cwd(), "../../docker/.env"), resolve(process.cwd(), ".env"), ]
prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/fix-incorrect-email-status.ts

possibleEnvPaths
Type : []
Default value : [ resolve(process.cwd(), "docker/.env"), // From apps/recallassess/recallassess-api/ resolve(process.cwd(), "../../docker/.env"), // From project root resolve(process.cwd(), ".env"), // From any location ]
prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/verify-email-status.ts

possibleEnvPaths
Type : []
Default value : [ resolve(process.cwd(), "docker/.env"), resolve(process.cwd(), "../../docker/.env"), resolve(process.cwd(), ".env"), ]
prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/post-bat-analysis-config.ts

PostBatAnalysisConfig
Type : unknown
Default value : new ReportConfig({ name: "post-bat-analysis", type: ReportType.paged, hasPagedJs: true, })

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/pre-bat-analysis-config.ts

PreBatAnalysisConfig
Type : unknown
Default value : new ReportConfig({ name: "pre-bat-analysis", type: ReportType.paged, hasPagedJs: true, })

apps/recallassess/recallassess-api/src/scripts/cleanup-failed-emails.ts

prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/delete-course-modules-online.ts

prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/delete-course.ts

prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/diagnose-email-logging-issues.ts

prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/scripts/lms-migration/delete-knowledge-review.ts

prisma
Type : unknown
Default value : new PrismaClient()

apps/recallassess/recallassess-api/src/config/billing.config.ts

PROCESSING_FEE_PERCENT
Type : number
Default value : 0.03

Central configuration for subscription billing fees (processing fee, VAT, etc.)

UAE_COUNTRY_CODE
Type : string
Default value : "AE"
UAE_VAT_PERCENT
Type : number
Default value : 0.05

apps/recallassess/recallassess-api/src/scripts/lms-migration/migrations/utils/mappings/quiz-to-assessment.mapping.ts

QUIZ_ID_TO_ASSESSMENT_MAP
Type : Record<number, literal type>
Default value : { 580: { courseCode: "CSM", title: "Consultative Selling Mastery Behavioural Statements", }, 383: { courseCode: "PIP", title: "Persuasion & Influence Behavioural Statements", }, 499: { courseCode: "DNP", title: "How to Develop Negotiation Power Behavioural Statements", }, 504: { courseCode: "CCT", title: "Collaborative Communication Techniques Behavioural Statements", }, // Add more quiz_id mappings here as needed }

Maps legacy quiz_id to assessment mapping. This is the primary mapping used for migrations.

Structure: { quiz_id: { courseCode: string, title: string, description?: string } }

QUIZ_TO_ASSESSMENT_MAP
Type : Record<string, literal type>
Default value : { "[eLearning] Consultative Selling Mastery Behavioural Statements": { courseCode: "CSM", title: "Consultative Selling Mastery Behavioural Statements", }, "[MASTER FULL DAY] Persuasion & Influence Behavioural Statements": { courseCode: "PIP", title: "Persuasion & Influence Behavioural Statements", }, "[MASTER FULL DAY] How to Develop Negotiation Power Behavioural Statements": { courseCode: "DNP", title: "How to Develop Negotiation Power (Online) Behavioural Statements", }, "[MASTER FULL DAY] Collaborative Communication Techniques Behavioural Statements v.2": { courseCode: "CCT", title: "Collaborative Communication Techniques Behavioural Statements", }, // Add more quiz mappings here as needed }

Maps legacy quiz records to new assessment records.

Structure: { "legacy-quiz-title": { courseCode: string, title: string, description?: string } }

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/report-configs.ts

reportConfigs
Type : []
Default value : [ PreBatAnalysisConfig, PostBatAnalysisConfig, ParticipantProgressConfig, ParticipantProgressSummaryConfig, InvoiceSummaryConfig, SubscriptionConfig, InvoiceConfig, TestConfig, ]

apps/recallassess/recallassess-api/src/api/shared/context/request-context.storage.ts

storage
Type : unknown
Default value : new AsyncLocalStorage<CLRequestContext>()

AsyncLocalStorage wrapper for CLRequestContext Provides request-scoped storage without conflicts with admin RequestContext

apps/recallassess/recallassess-api/src/api/shared/email/subscription-banner-allowed-template-keys.ts

SUBSCRIPTION_BANNER_ALLOWED_TEMPLATE_KEYS
Type : unknown
Default value : new Set<string>([ "course.hundred.day.journey.email1", "course.hundred.day.journey.email2", "course.hundred.day.journey.email3", "course.hundred.day.journey.email4", "hundred.day.journey.email1", "hundred.day.journey.email2", "hundred.day.journey.email3", "hundred.day.journey.email4", ])

Only these templates may show the subscription-expired banner when the company's subscription is not ACTIVE. All other templates must not show it.

Matches 100-Day Journey (100DJ) emails 1–4 (current and legacy template keys).

SUBSCRIPTION_BANNER_EMAIL_TEMPLATE_TYPE
Type : string
Default value : "HUNDRED_DAY_JOURNEY"

100DJ rows in RecallAssess seeds use this enum value; KR completion uses COURSE_ASSIGNMENT.

apps/recallassess/recallassess-api/src/api/shared/email/subscription-expired-banner-html.util.ts

SUBSCRIPTION_EXPIRED_BANNER_MARKER
Type : string
Default value : "YOUR SUBSCRIPTION HAS EXPIRED!!!"

Visible marker used to avoid double-inserting the banner

SUBSCRIPTION_EXPIRED_BANNER_TEMPLATE_KEY
Type : string
Default value : "course.hundred.day.journey.subscription.expired.banner"

Template-key for the admin-editable subscription-expired banner template stored in the email_template table. Loaded by loadSubscriptionExpiredBannerHtml and seeded by 010-100dj-subscription-expired-banner-template.sql, then normalized to Outlook-safe markup by migration 20260506150000_outlook_safe_subscription_expired_banner_template.

apps/recallassess/recallassess-api/src/api/client/shared/participant-subscription-course-access.service.ts

SUBSCRIPTION_EXPIRED_COURSE_ACCESS_MESSAGE
Type : string
Default value : "Your company subscription has expired. You can still open completed courses to review the results. To start or continue training, please ask your administrator to renew the subscription"

Shown when the participant tries to start/continue training while the company subscription is inactive. Completed enrollments may still view results (read-only).

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/subscription-config.ts

SubscriptionConfig
Type : unknown
Default value : new ReportConfig({ name: "subscription", title: "Subscription payments", type: ReportType.fluid, hasXlsxExport: true, hasCsvExport: true, xlsxPattern: ReportXlsxPattern.Code, xlsxFileNamePrefix: "subscription-payments", xlsxSheetName: "Subscription payments", xlsxColumns: [ { key: "payment_date", header: "Payment / Refund Date", type: ReportXlsxColumnType.Date }, { key: "customer_name_email", header: "Customer Name / Email" }, { key: "company_name", header: "Company" }, { key: "product_service", header: "Product / Service subscribed" }, { key: "currency", header: "Currency" }, { key: "amount", header: "Amount", type: ReportXlsxColumnType.Number }, { key: "amount_refunded", header: "Amount Refunded", type: ReportXlsxColumnType.Number }, { key: "fee", header: "Fee", type: ReportXlsxColumnType.Number }, { key: "vat", header: "VAT", type: ReportXlsxColumnType.Number }, { key: "net_amount", header: "Net Amount", type: ReportXlsxColumnType.Number }, ], })

apps/recallassess/recallassess-api/src/api/admin/report/report-configs/reports/test-config.ts

TestConfig
Type : unknown
Default value : new ReportConfig({ name: "test-report", type: ReportType.paged, hasPagedJs: true, })

apps/recallassess/recallassess-api/src/api/admin/system-log/dto/system-log-detail.dto.ts

transformJsonField
Type : unknown
Default value : ({ value }: { value: any }): string | null => { if (value === null || value === undefined) { return null; } // If it's already a string, try to parse and re-stringify to ensure it's valid JSON if (typeof value === "string") { try { const parsed = JSON.parse(value); // Return formatted JSON string with indentation for readability return JSON.stringify(parsed, null, 2); } catch { // If parsing fails, return as-is (might be a plain string) return value; } } // If it's an object or array, stringify it with formatting if (typeof value === "object") { try { return JSON.stringify(value, null, 2); } catch { return null; } } return String(value); }

apps/recallassess/recallassess-api/src/config/trial-package.config.ts

TRIAL_PACKAGE_CONFIG
Type : unknown
Default value : { /** * Percentage of course pages allowed for trial packages * Example: 50 = 50% of course pages are accessible * Can be changed to 30% or other values as needed */ PAGE_LIMIT_PERCENTAGE: 50, } as const

Trial Package Configuration

Centralized configuration for trial package limitations. These values define restrictions for trial package users. Update these values in one place to affect all trial package logic.

apps/recallassess/recallassess-api/src/config/vip-package.config.ts

VIP_PACKAGE_CONFIG
Type : unknown
Default value : { /** * Course title patterns for VIP package (PRIVATE_VIP_TRIAL) * These patterns are used to filter courses in the license allocation dropdown * for companies on the VIP package. * Example: ["How to Persuade & Influence People", "How to Develop Negotiation Power"] * Can be changed to add or remove courses as needed */ ALLOWED_COURSE_PATTERNS: [ "How to Persuade & Influence People", "How to Develop Negotiation Power", // Matches both "How to Develop Negotiation Power" and "How to Develop Negotiation Power - GENERAL" "Collaborative Communications Techniques", "Consultative Selling Mastery", ] as const, } as const

VIP Package Configuration

Centralized configuration for VIP package (PRIVATE_VIP_TRIAL) course restrictions. These values define which courses are allowed for VIP package users. Update these values in one place to affect all VIP package logic.

apps/recallassess/recallassess-api/src/api/client/package/vip-trial-landing-content.ts

VIP_TRIAL_LANDING_COPY
Type : unknown
Default value : { /** Drives: (1) section intro under the title, (2) card body β€” same `description` field in the template. */ description: "Experience Recall with a 30-day VIP trial for 5 people. Includes all Start-Up features and access to How to Persuade & Influence People, How to Develop Negotiation Power, Consultative Selling Mastery, and Collaborative Communications Techniques.", /** * Line-separated: each non-empty line becomes one "What's included" item. The "Access to…" line is (3). */ features: `30-day free trial 5 concurrent licenses All Start-Up package features Access to How to Persuade & Influence People, How to Develop Negotiation Power, Consultative Selling Mastery, and Collaborative Communications Techniques First month FREE, then 50% off months 2-3 FREE Recall credits with 3-month subscription Continue with Start-up, Growth or Enterprise packages`, } as const

Authoritative marketing copy for the Private VIP trial landing (?sps=private-vip-trial-offer). Applied in CLPackageService so the PWA always receives the current course list even if a DB row was never migrated or was edited in admin to an older string. Keep in sync with prisma migrations that UPDATE package WHERE package_type = 'PRIVATE_VIP_TRIAL'.

results matching ""

    No results matching ""