| 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:
|
| appInstance |
Type : NestFastifyApplication | null
|
Default value : null
|
| isShuttingDown |
Type : unknown
|
Default value : false
|
| module |
Type : any
|
| 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"
|
| 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"
|
| 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"
|
| 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 |
| 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 |
| BILLING_MONTH_DAYS |
Type : number
|
Default value : 30
|
|
Billing uses fixed 30-day "months" (not calendar months).
|
| 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"
|
| cachedUserId |
Type : number | null
|
Default value : null
|
| 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,
},
});
}
},
}
|
| 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 |
| 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:
To expand:
|
| DEFAULT_TIMEZONE |
Type : string
|
Default value : "UTC"
|
| COURSE_CODES |
Type : []
|
Default value : ["CCT-Online", "CSM-Online", "PIP-Online"]
|
| EXCLUDED_QUIZ_TITLE |
Type : string
|
Default value : "PERSUASION & INFLUENCING PSYCHOLOGY KNOWLEDGE REVIEW"
|
| COURSE_MODULE_PAGE_DIFFICULTY_LEVEL_VALUES |
Type : unknown
|
Default value : [
"FOUNDATION",
"INTERMEDIATE",
"ADVANCED",
"EXPERT",
] as const satisfies readonly DifficultyLevel[]
|
|
Matches Prisma |
| 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) ---
--- 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= |
| 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. |
| 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 |
| courseLinks |
Type : LinkConfig[]
|
Default value : [getCourseModulePageLink()]
|
| 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
}
},
}
|
| 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,
},
});
},
}
|
| courseModuleLinks |
Type : LinkConfig[]
|
Default value : [getCourseModulePageLink()]
|
| 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
}
},
}
|
| 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(", ")
|
| 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()
|
| 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()
|
| 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. |
| 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()
|
| 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) |
| DATE_ONLY_RE |
Type : unknown
|
Default value : /^\d{4}-\d{2}-\d{2}$/
|
| DEFAULT_BATCH_SIZE |
Type : number
|
Default value : 1000
|
| 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) |
| 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. |
| 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"],
},
]
|
| 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
|
| 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
|
| 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;
}
},
}
|
| seedFilesCache |
Type : [] | null
|
Default value : null
|
| 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 |
| EXCLUDE_FROM_BAT |
Type : Record<string, boolean>
|
Default value : {
"DNP-Conclusion": true,
"PIP-Intro": true,
"CCT-Intro": true,
"CSM-Intro": true,
}
|
| 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:
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. |
| 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. |
| InvoiceConfig |
Type : unknown
|
Default value : new ReportConfig({
name: "invoice",
title: "Invoice",
type: ReportType.fluid,
})
|
|
Single-invoice printable report (browser/PDF); spreadsheet export is on |
| 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 },
],
})
|
| 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(", ")
: "''"
|
| 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(", ")
: "''"
|
| 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(", ")
: "''"
|
| KnowledgeReviewSubmissionType |
Type : object
|
Default value : {
COURSE_LEVEL: "COURSE_LEVEL" as const,
EMBEDDED_QUIZ: "EMBEDDED_QUIZ" as const,
EMBEDDED_ASSIGNMENT: "EMBEDDED_ASSIGNMENT" as const,
}
|
| LEGACY_TO_NEW_CODE |
Type : Record<string, string>
|
Default value : {
"PIP-Online": "PIP",
"CSM-Online": "CSM",
"CCT-Online": "CCT",
"DNP-Online": "DNP",
}
|
| 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 |
| MAX_INVOICE_ROWS |
Type : number
|
Default value : 5000
|
| MAX_SUBSCRIPTION_PAYMENT_ROWS |
Type : number
|
Default value : 10_000
|
| 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,
});
}
},
}
|
| 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 |
| 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 |
| ParticipantProgressConfig |
Type : unknown
|
Default value : new ReportConfig({
name: "participant-progress",
title: "Participant Progress",
type: ReportType.fluid,
})
|
| 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 },
],
})
|
| PASSWORD_REQUIREMENTS |
Type : unknown
|
Default value : {
minLength: 8,
uppercase: /[A-Z]/,
number: /\d/,
special: /[\W_]/,
} as const
|
| 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. |
| prisma |
Type : unknown
|
Default value : new PrismaClient()
|
| 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()
|
| 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()
|
| prisma |
Type : unknown
|
Default value : new PrismaClient()
|
| 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()
|
| PostBatAnalysisConfig |
Type : unknown
|
Default value : new ReportConfig({
name: "post-bat-analysis",
type: ReportType.paged,
hasPagedJs: true,
})
|
| PreBatAnalysisConfig |
Type : unknown
|
Default value : new ReportConfig({
name: "pre-bat-analysis",
type: ReportType.paged,
hasPagedJs: true,
})
|
| prisma |
Type : unknown
|
Default value : new PrismaClient()
|
| prisma |
Type : unknown
|
Default value : new PrismaClient()
|
| prisma |
Type : unknown
|
Default value : new PrismaClient()
|
| prisma |
Type : unknown
|
Default value : new PrismaClient()
|
| prisma |
Type : unknown
|
Default value : new PrismaClient()
|
| 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
|
| storage |
Type : unknown
|
Default value : new AsyncLocalStorage<CLRequestContext>()
|
|
AsyncLocalStorage wrapper for CLRequestContext Provides request-scoped storage without conflicts with admin RequestContext |
| 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. |
| 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 |
| 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). |
| 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 },
],
})
|
| TestConfig |
Type : unknown
|
Default value : new ReportConfig({
name: "test-report",
type: ReportType.paged,
hasPagedJs: true,
})
|
| 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);
}
|
| 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. |
| 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. |
| 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 ( |