feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+485
View File
@@ -0,0 +1,485 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import {
countAssignmentsByStatus,
createProgramGoal,
deleteProgramGoal,
getAssignmentsForProfile,
getLatestCompletionRecordsForProfilesAndCourses,
getMaxScoresForProfilesAndCourses,
getNonComplianceCourseCompletions,
getProgramById,
getProgramGoalAssignments,
getProgramGoalById,
getProgramGoals,
getProgramGoalsByOrg,
getProgramMembers,
listAllActiveProgramGoals,
listAssignmentsForReminderScan,
type TNewProgramGoalAssignment,
type TProgramGoal,
type TProgramGoalAssignment,
updateProgramGoal as updateProgramGoalQuery,
upsertProgramGoalAssignments
} from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants';
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
const AT_RISK_DAYS = 3;
const DAY_MS = 24 * 60 * 60 * 1000;
type GoalType = TProgramGoal['type'];
// ─── CRUD ────────────────────────────────────────────────────────────────────
export async function createGoal(
programId: string,
profileId: string,
data: TCreateProgramGoal
): Promise<TProgramGoal> {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
try {
const goal = await createProgramGoal({
programId,
title: data.title,
description: data.description ?? null,
type: data.type,
courseIds: data.courseIds,
requiredCount: data.requiredCount ?? null,
scoreThreshold: data.scoreThreshold ?? null,
teamPassRateThreshold: data.teamPassRateThreshold ?? null,
deadlineKind: data.deadlineKind,
deadlineDate: data.deadlineDate ?? null,
relativeDays: data.relativeDays ?? null,
recurringMonths: data.recurringMonths ?? null,
reminderDaysBefore: data.reminderDaysBefore,
createdByProfileId: profileId
});
await evaluateGoal(goal.id).catch((error) => {
console.error('createGoal: initial evaluation failed', error);
});
return goal;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create goal',
ErrorCodes.PROGRAM_GOAL_CREATE_FAILED,
500
);
}
}
export async function listGoals(
programId: string
): Promise<Array<TProgramGoal & { statusCounts: Record<string, number> }>> {
const goals = await getProgramGoals(programId);
if (goals.length === 0) return [];
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
return goals.map((goal) => ({
...goal,
statusCounts: counts.get(goal.id) ?? {}
}));
}
export async function getGoal(goalId: string): Promise<TProgramGoal> {
const goal = await getProgramGoalById(goalId);
if (!goal) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
return goal;
}
export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Promise<TProgramGoal> {
const existing = await getProgramGoalById(goalId);
if (!existing) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
try {
const updated = await updateProgramGoalQuery(goalId, {
title: data.title,
description: data.description ?? undefined,
type: data.type,
courseIds: data.courseIds,
requiredCount: data.requiredCount ?? null,
scoreThreshold: data.scoreThreshold ?? null,
teamPassRateThreshold: data.teamPassRateThreshold ?? null,
reminderDaysBefore: data.reminderDaysBefore,
deadlineKind: data.deadlineKind,
deadlineDate: data.deadlineDate ?? null,
relativeDays: data.relativeDays ?? null,
recurringMonths: data.recurringMonths ?? null,
status: data.status
});
if (!updated) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
await evaluateGoal(goalId).catch((error) => {
console.error('updateGoal: re-evaluation failed', error);
});
return updated;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update goal',
ErrorCodes.PROGRAM_GOAL_UPDATE_FAILED,
500
);
}
}
export async function archiveGoal(goalId: string): Promise<TProgramGoal> {
return updateGoal(goalId, { status: 'archived' });
}
export async function removeGoal(goalId: string): Promise<TProgramGoal> {
const deleted = await deleteProgramGoal(goalId);
if (!deleted) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
return deleted;
}
// ─── Evaluator ───────────────────────────────────────────────────────────────
/**
* Compute due date for one learner against a goal.
*
* - none / readiness → null
* - absolute → goal.deadlineDate
* - relative_to_join → joinedAt + relativeDays
* - recurring → goal.deadlineDate (current cycle); cycle advance handled elsewhere
*/
function computeDueDate(goal: TProgramGoal, joinedAt: Date): Date | null {
if (goal.deadlineKind === 'none') return null;
if (goal.deadlineKind === 'absolute') {
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
}
if (goal.deadlineKind === 'relative_to_join') {
if (!goal.relativeDays) return null;
return new Date(joinedAt.getTime() + goal.relativeDays * DAY_MS);
}
if (goal.deadlineKind === 'recurring') {
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
}
return null;
}
/**
* Decide a per-learner status from the cached counts and dates.
*
* Stays completed once it reaches `completed` (cron resets handle recurring).
*/
function decideStatus(args: {
completed: number;
required: number;
dueDate: Date | null;
now: Date;
previousStatus: TProgramGoalAssignment['status'] | null;
}): { status: TProgramGoalAssignment['status']; completedAt: Date | null } {
const { completed, required, dueDate, now, previousStatus } = args;
if (previousStatus === 'waived') {
return { status: 'waived', completedAt: null };
}
if (required > 0 && completed >= required) {
return { status: 'completed', completedAt: now };
}
if (dueDate && now.getTime() > dueDate.getTime()) {
return { status: 'overdue', completedAt: null };
}
if (dueDate && dueDate.getTime() - now.getTime() < AT_RISK_DAYS * DAY_MS) {
return { status: 'at_risk', completedAt: null };
}
if (completed > 0) {
return { status: 'in_progress', completedAt: null };
}
return { status: 'not_started', completedAt: null };
}
/**
* Re-evaluate every assignment for one goal.
*
* Pulls all student program-members, computes a per-learner status, and upserts
* `program_goal_assignment`. Designed to be safe to run repeatedly (idempotent)
* and cheap enough for daily cron over hundreds of programs.
*/
export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }> {
const goal = await getProgramGoalById(goalId);
if (!goal) return { evaluated: 0 };
const members = await getProgramMembers(goal.programId);
const studentMembers = members.filter((member) => member.roleId === ROLE.STUDENT && member.profileId);
if (studentMembers.length === 0) return { evaluated: 0 };
const profileIds = studentMembers.map((member) => member.profileId!);
const courseIds = goal.courseIds ?? [];
const [completionRecords, nonComplianceCompletions, scoreMap, existingAssignments] = await Promise.all([
getLatestCompletionRecordsForProfilesAndCourses(profileIds, courseIds),
getNonComplianceCourseCompletions(profileIds, courseIds),
goal.type === 'score' || goal.type === 'pass_rate'
? getMaxScoresForProfilesAndCourses(profileIds, courseIds)
: Promise.resolve(new Map<string, number>()),
getProgramGoalAssignments(goalId)
]);
const completionByKey = new Map<string, { status: string; score: number | null }>();
for (const record of completionRecords) {
completionByKey.set(`${record.profileId}:${record.courseId}`, {
status: record.status,
score: record.score
});
}
const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.programMemberId, assignment]));
const now = new Date();
const requiredFor = (type: GoalType, total: number) =>
type === 'n_of_m' && goal.requiredCount ? Math.min(goal.requiredCount, total) : total;
const rows: TNewProgramGoalAssignment[] = [];
for (const member of studentMembers) {
if (!member.profileId) continue;
const joinedAt = member.createdAt ? new Date(member.createdAt) : now;
const dueDate = computeDueDate(goal, joinedAt);
const completedCourseIds = courseIds.filter((courseId) => {
const key = `${member.profileId}:${courseId}`;
const record = completionByKey.get(key);
if (record) {
if (goal.type === 'score' || goal.type === 'pass_rate') {
const score = scoreMap.get(key);
return typeof score === 'number' && score >= (goal.scoreThreshold ?? 100);
}
if (record.status === 'compliant') return true;
}
// Fallback: non-compliance course where every lesson is completed.
if (goal.type !== 'score' && goal.type !== 'pass_rate') {
return nonComplianceCompletions.has(key);
}
return false;
});
const completed = completedCourseIds.length;
const required = requiredFor(goal.type, courseIds.length);
const previous = previousByMember.get(member.id);
const decision = decideStatus({
completed,
required,
dueDate,
now,
previousStatus: previous?.status ?? null
});
rows.push({
goalId,
programMemberId: member.id,
dueDate: dueDate ? dueDate.toISOString() : null,
status: decision.status,
completedCount: completed,
requiredCount: required,
completedAt:
decision.status === 'completed' ? (previous?.completedAt ?? decision.completedAt?.toISOString() ?? null) : null,
lastEvaluatedAt: now.toISOString()
});
}
await upsertProgramGoalAssignments(rows);
return { evaluated: rows.length };
}
/**
* Re-evaluate every active goal in a program. Used after material program
* changes (member added, course added) or by cron sweeps.
*/
export async function evaluateProgramGoals(programId: string): Promise<{ evaluated: number }> {
const goals = await getProgramGoals(programId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
evaluated += result.evaluated;
}
return { evaluated };
}
/**
* Re-evaluate all goals in an organization. Used by the daily cron.
*/
export async function evaluateOrgGoals(organizationId: string): Promise<{ evaluated: number; goals: number }> {
const goals = await getProgramGoalsByOrg(organizationId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
evaluated += result.evaluated;
}
return { evaluated, goals: goals.length };
}
/**
* Re-evaluate every active goal across every org. Designed for the daily cron
* sweep — same shape as `runComplianceExpiryCheck` for the compliance feature.
*/
export async function runProgramGoalEvaluationSweep(): Promise<{
goalsEvaluated: number;
assignmentsEvaluated: number;
}> {
const goals = await listAllActiveProgramGoals();
let assignmentsEvaluated = 0;
for (const goal of goals) {
try {
const result = await evaluateGoal(goal.id);
assignmentsEvaluated += result.evaluated;
} catch (error) {
console.error(`runProgramGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error);
}
}
return { goalsEvaluated: goals.length, assignmentsEvaluated };
}
// ─── Reminder scan ───────────────────────────────────────────────────────────
function getWholeDaysUntil(now: Date, target: Date): number {
const ms = target.getTime() - now.getTime();
return Math.ceil(ms / DAY_MS);
}
/**
* Scan all active goal assignments and send a reminder email when
* `daysUntilDue` matches an entry in the goal's `reminderDaysBefore` cadence
* (mirrors `runComplianceReminderScan`).
*
* Idempotency: the email worker idempotency key is keyed on assignment id +
* the day bucket so a second run on the same day does not re-send.
*/
export async function runProgramGoalReminderScan(): Promise<{
scanned: number;
remindersEnqueued: number;
}> {
const now = new Date();
const rows = await listAssignmentsForReminderScan();
let remindersEnqueued = 0;
for (const row of rows) {
if (!row.assignment.dueDate) continue;
if (!row.email) continue;
const dueDate = new Date(row.assignment.dueDate);
if (Number.isNaN(dueDate.getTime())) continue;
const daysUntilDue = getWholeDaysUntil(now, dueDate);
const reminderDays = row.goal.reminderDaysBefore ?? [];
const isOverdueDay = daysUntilDue === 0;
const matchesReminder = (daysUntilDue > 0 && reminderDays.includes(daysUntilDue)) || isOverdueDay;
if (!matchesReminder) continue;
const loginUrl = getDashboardBaseUrl(row.organizationSiteName ?? undefined);
const dayKey = now.toISOString().slice(0, 10);
try {
await enqueueTransactionalEmail('programGoalReminder', {
to: row.email,
fields: {
orgName: row.organizationName,
programName: row.programName,
goalTitle: row.goal.title,
daysUntilDue,
completedCount: row.assignment.completedCount ?? 0,
requiredCount: row.assignment.requiredCount ?? 0,
loginUrl: `${loginUrl}/lms/programs/${row.programId}`
},
idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
});
remindersEnqueued += 1;
} catch (error) {
console.error(`runProgramGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error);
}
}
return { scanned: rows.length, remindersEnqueued };
}
// ─── LMS read ────────────────────────────────────────────────────────────────
export async function getMyGoals(profileId: string) {
return getAssignmentsForProfile(profileId);
}
// ─── Org-wide overview ───────────────────────────────────────────────────────
/**
* Cross-program goal roll-up for the org owner. Lists every active goal in the
* org with its per-status counts so the dashboard can group by program.
*/
export async function getOrgGoalsOverview(organizationId: string) {
const goals = await getProgramGoalsByOrg(organizationId);
if (goals.length === 0) {
return { goals: [] };
}
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
return {
goals: goals.map((goal) => {
const statusCounts = counts.get(goal.id) ?? {};
const total = Object.values(statusCounts).reduce((sum, value) => sum + (value ?? 0), 0);
const completed = statusCounts.completed ?? 0;
const overdue = statusCounts.overdue ?? 0;
const atRisk = statusCounts.at_risk ?? 0;
const onTrack = total - overdue - atRisk;
const onTrackPct = total === 0 ? 0 : Math.round((onTrack / total) * 100);
return {
goalId: goal.id,
programId: goal.programId,
programName: goal.programName,
title: goal.title,
type: goal.type,
deadlineKind: goal.deadlineKind,
totalLearners: total,
completedCount: completed,
inProgressCount: statusCounts.in_progress ?? 0,
atRiskCount: atRisk,
overdueCount: overdue,
notStartedCount: statusCounts.not_started ?? 0,
waivedCount: statusCounts.waived ?? 0,
onTrackPct
};
})
};
}
+52
View File
@@ -0,0 +1,52 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getProgramById } from '@cio/db/queries/program';
import type { TInviteStudentsToProgram, TAssignExistingStudentsToProgram } from '@cio/utils/validation/program';
import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience';
/**
* Invite students to a single program by email CSV.
*
* Thin wrapper that hardcodes `programIds = [programId]` and delegates to
* `importAudienceMembers`. Lets a Program ADMIN/TUTOR (or org admin) issue org
* invites scoped to *their own* program, without granting org-wide invite rights.
*/
export async function inviteStudentsToProgram(
programId: string,
data: TInviteStudentsToProgram,
invitedByProfileId: string
) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return importAudienceMembers(
program.organizationId,
{
recipientCsv: data.recipientCsv,
programIds: [programId],
sendEmail: data.sendEmail,
allCourses: false,
allPrograms: false
},
invitedByProfileId
);
}
/**
* Assign existing student profiles (already members of the org audience) to a
* single program. Same scoping logic as `inviteStudentsToProgram` — restricted
* to one program at a time so the caller's program-admin permission is enough.
*/
export async function assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return assignAudienceToCourses(program.organizationId, {
profileIds: data.profileIds,
programIds: [programId],
sendEmail: data.sendEmail
});
}