2026-05-18 03:47:22 +01:00
|
|
|
import * as schema from '@db/schema';
|
|
|
|
|
|
|
|
|
|
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
import { db } from '@db/drizzle';
|
|
|
|
|
|
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export type TProgramGoal = typeof schema.programGoal.$inferSelect;
|
|
|
|
|
export type TNewProgramGoal = typeof schema.programGoal.$inferInsert;
|
|
|
|
|
export type TProgramGoalAssignment = typeof schema.programGoalAssignment.$inferSelect;
|
|
|
|
|
export type TNewProgramGoalAssignment = typeof schema.programGoalAssignment.$inferInsert;
|
|
|
|
|
|
|
|
|
|
// ─── Goal CRUD ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function createProgramGoal(data: TNewProgramGoal): Promise<TProgramGoal> {
|
|
|
|
|
try {
|
|
|
|
|
const [goal] = await db.insert(schema.programGoal).values(data).returning();
|
|
|
|
|
if (!goal) throw new Error('Failed to create program goal');
|
|
|
|
|
|
|
|
|
|
return goal;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('createProgramGoal error:', error);
|
|
|
|
|
throw new Error(`Failed to create program goal: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getProgramGoalById(goalId: string): Promise<TProgramGoal | null> {
|
|
|
|
|
try {
|
|
|
|
|
const [goal] = await db.select().from(schema.programGoal).where(eq(schema.programGoal.id, goalId)).limit(1);
|
|
|
|
|
|
|
|
|
|
return goal ?? null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getProgramGoalById error:', error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Failed to get program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getProgramGoals(
|
|
|
|
|
programId: string,
|
|
|
|
|
options: { includeArchived?: boolean } = {}
|
|
|
|
|
): Promise<TProgramGoal[]> {
|
|
|
|
|
try {
|
|
|
|
|
const conditions = [eq(schema.programGoal.programId, programId)];
|
|
|
|
|
if (!options.includeArchived) {
|
|
|
|
|
conditions.push(eq(schema.programGoal.status, 'active'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(schema.programGoal)
|
|
|
|
|
.where(and(...conditions))
|
|
|
|
|
.orderBy(asc(schema.programGoal.createdAt));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getProgramGoals error:', error);
|
|
|
|
|
throw new Error(`Failed to list program goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getProgramGoalsByOrg(
|
|
|
|
|
organizationId: string
|
|
|
|
|
): Promise<Array<TProgramGoal & { programId: string; programName: string }>> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await db
|
|
|
|
|
.select({
|
|
|
|
|
goal: schema.programGoal,
|
|
|
|
|
programName: schema.program.name
|
|
|
|
|
})
|
|
|
|
|
.from(schema.programGoal)
|
|
|
|
|
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
|
|
|
|
.where(and(eq(schema.program.organizationId, organizationId), eq(schema.programGoal.status, 'active')));
|
|
|
|
|
|
|
|
|
|
return result.map((row) => ({ ...row.goal, programName: row.programName }));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getProgramGoalsByOrg error:', error);
|
|
|
|
|
throw new Error(`Failed to list goals for org: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Every active goal across every org. Used by the daily cron sweep. */
|
|
|
|
|
export async function listAllActiveProgramGoals(): Promise<TProgramGoal[]> {
|
|
|
|
|
try {
|
|
|
|
|
return await db.select().from(schema.programGoal).where(eq(schema.programGoal.status, 'active'));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('listAllActiveProgramGoals error:', error);
|
|
|
|
|
throw new Error(`Failed to list active goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* For the reminder scan: every assignment whose `dueDate` is in the future and
|
|
|
|
|
* whose status is not yet completed/waived. The caller decides the cadence
|
|
|
|
|
* (compares daysUntilDue against goal.reminderDaysBefore).
|
|
|
|
|
*/
|
|
|
|
|
export async function listAssignmentsForReminderScan(): Promise<
|
|
|
|
|
Array<{
|
|
|
|
|
assignment: TProgramGoalAssignment;
|
|
|
|
|
goal: TProgramGoal;
|
|
|
|
|
profileId: string;
|
|
|
|
|
email: string | null;
|
|
|
|
|
programId: string;
|
|
|
|
|
programName: string;
|
|
|
|
|
organizationName: string;
|
|
|
|
|
organizationSiteName: string | null;
|
2026-06-07 18:43:05 +01:00
|
|
|
organizationCustomDomain: string | null;
|
|
|
|
|
organizationIsCustomDomainVerified: boolean | null;
|
2026-06-22 05:43:48 +01:00
|
|
|
organizationAvatarUrl: string | null;
|
|
|
|
|
organizationTheme: string | null;
|
2026-05-18 03:47:22 +01:00
|
|
|
}>
|
|
|
|
|
> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await db
|
|
|
|
|
.select({
|
|
|
|
|
assignment: schema.programGoalAssignment,
|
|
|
|
|
goal: schema.programGoal,
|
|
|
|
|
profileId: schema.programMember.profileId,
|
|
|
|
|
email: schema.programMember.email,
|
|
|
|
|
programId: schema.program.id,
|
|
|
|
|
programName: schema.program.name,
|
|
|
|
|
organizationName: schema.organization.name,
|
2026-06-07 18:43:05 +01:00
|
|
|
organizationSiteName: schema.organization.siteName,
|
|
|
|
|
organizationCustomDomain: schema.organization.customDomain,
|
2026-06-22 05:43:48 +01:00
|
|
|
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
|
|
|
|
|
organizationAvatarUrl: schema.organization.avatarUrl,
|
|
|
|
|
organizationTheme: schema.organization.theme
|
2026-05-18 03:47:22 +01:00
|
|
|
})
|
|
|
|
|
.from(schema.programGoalAssignment)
|
|
|
|
|
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
|
|
|
|
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
|
|
|
|
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
|
|
|
|
.innerJoin(schema.organization, eq(schema.program.organizationId, schema.organization.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(schema.programGoal.status, 'active'),
|
|
|
|
|
inArray(schema.programGoalAssignment.status, ['not_started', 'in_progress', 'at_risk', 'overdue'])
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
.filter((row) => row.profileId != null)
|
|
|
|
|
.map((row) => ({
|
|
|
|
|
assignment: row.assignment,
|
|
|
|
|
goal: row.goal,
|
|
|
|
|
profileId: row.profileId!,
|
|
|
|
|
email: row.email,
|
|
|
|
|
programId: row.programId,
|
|
|
|
|
programName: row.programName,
|
|
|
|
|
organizationName: row.organizationName,
|
2026-06-07 18:43:05 +01:00
|
|
|
organizationSiteName: row.organizationSiteName,
|
|
|
|
|
organizationCustomDomain: row.organizationCustomDomain,
|
2026-06-22 05:43:48 +01:00
|
|
|
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified,
|
|
|
|
|
organizationAvatarUrl: row.organizationAvatarUrl,
|
|
|
|
|
organizationTheme: row.organizationTheme
|
2026-05-18 03:47:22 +01:00
|
|
|
}));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('listAssignmentsForReminderScan error:', error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Failed to list assignments for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateProgramGoal(goalId: string, data: Partial<TNewProgramGoal>): Promise<TProgramGoal | null> {
|
|
|
|
|
try {
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(schema.programGoal)
|
|
|
|
|
.set({ ...data, updatedAt: sql`now()` })
|
|
|
|
|
.where(eq(schema.programGoal.id, goalId))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
return updated ?? null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('updateProgramGoal error:', error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Failed to update program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteProgramGoal(goalId: string): Promise<TProgramGoal | null> {
|
|
|
|
|
try {
|
|
|
|
|
const [deleted] = await db.delete(schema.programGoal).where(eq(schema.programGoal.id, goalId)).returning();
|
|
|
|
|
|
|
|
|
|
return deleted ?? null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('deleteProgramGoal error:', error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Failed to delete program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Goal Assignments ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function upsertProgramGoalAssignments(
|
|
|
|
|
rows: TNewProgramGoalAssignment[]
|
|
|
|
|
): Promise<TProgramGoalAssignment[]> {
|
|
|
|
|
if (rows.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await db
|
|
|
|
|
.insert(schema.programGoalAssignment)
|
|
|
|
|
.values(rows)
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
target: [schema.programGoalAssignment.goalId, schema.programGoalAssignment.programMemberId],
|
|
|
|
|
set: {
|
|
|
|
|
dueDate: sql`excluded.due_date`,
|
|
|
|
|
status: sql`excluded.status`,
|
|
|
|
|
completedCount: sql`excluded.completed_count`,
|
|
|
|
|
requiredCount: sql`excluded.required_count`,
|
|
|
|
|
completedAt: sql`excluded.completed_at`,
|
|
|
|
|
lastEvaluatedAt: sql`excluded.last_evaluated_at`,
|
|
|
|
|
updatedAt: sql`now()`
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('upsertProgramGoalAssignments error:', error);
|
|
|
|
|
throw new Error(`Failed to upsert goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getProgramGoalAssignments(goalId: string): Promise<TProgramGoalAssignment[]> {
|
|
|
|
|
try {
|
|
|
|
|
return await db.select().from(schema.programGoalAssignment).where(eq(schema.programGoalAssignment.goalId, goalId));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getProgramGoalAssignments error:', error);
|
|
|
|
|
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getAssignmentsForProgramMembers(programMemberIds: string[]): Promise<TProgramGoalAssignment[]> {
|
|
|
|
|
if (programMemberIds.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(schema.programGoalAssignment)
|
|
|
|
|
.where(inArray(schema.programGoalAssignment.programMemberId, programMemberIds));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getAssignmentsForProgramMembers error:', error);
|
|
|
|
|
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getAssignmentsForProfile(profileId: string): Promise<
|
|
|
|
|
Array<
|
|
|
|
|
TProgramGoalAssignment & {
|
|
|
|
|
goal: TProgramGoal;
|
|
|
|
|
programId: string;
|
|
|
|
|
programName: string;
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await db
|
|
|
|
|
.select({
|
|
|
|
|
assignment: schema.programGoalAssignment,
|
|
|
|
|
goal: schema.programGoal,
|
|
|
|
|
programId: schema.program.id,
|
|
|
|
|
programName: schema.program.name
|
|
|
|
|
})
|
|
|
|
|
.from(schema.programGoalAssignment)
|
|
|
|
|
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
|
|
|
|
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
|
|
|
|
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
|
|
|
|
.where(and(eq(schema.programMember.profileId, profileId), eq(schema.programGoal.status, 'active')));
|
|
|
|
|
|
|
|
|
|
return result.map((row) => ({
|
|
|
|
|
...row.assignment,
|
|
|
|
|
goal: row.goal,
|
|
|
|
|
programId: row.programId,
|
|
|
|
|
programName: row.programName
|
|
|
|
|
}));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getAssignmentsForProfile error:', error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Failed to get goal assignments for profile: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Per-goal status counts for a program (used by dashboard tiles).
|
|
|
|
|
*/
|
|
|
|
|
export async function countAssignmentsByStatus(goalIds: string[]): Promise<Map<string, Record<string, number>>> {
|
|
|
|
|
if (goalIds.length === 0) return new Map();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
goalId: schema.programGoalAssignment.goalId,
|
|
|
|
|
status: schema.programGoalAssignment.status,
|
|
|
|
|
count: sql<number>`count(*)::int`.as('count')
|
|
|
|
|
})
|
|
|
|
|
.from(schema.programGoalAssignment)
|
|
|
|
|
.where(inArray(schema.programGoalAssignment.goalId, goalIds))
|
|
|
|
|
.groupBy(schema.programGoalAssignment.goalId, schema.programGoalAssignment.status);
|
|
|
|
|
|
|
|
|
|
const map = new Map<string, Record<string, number>>();
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
const bucket = map.get(row.goalId) ?? {};
|
|
|
|
|
bucket[row.status] = Number(row.count) || 0;
|
|
|
|
|
map.set(row.goalId, bucket);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('countAssignmentsByStatus error:', error);
|
|
|
|
|
throw new Error(`Failed to count goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Evaluator Inputs ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* For each (profile, course) pair, return the most recent course_completion_record
|
|
|
|
|
* (cycle-aware compliance status) plus optional score. Used by the goal evaluator.
|
|
|
|
|
*/
|
|
|
|
|
export async function getLatestCompletionRecordsForProfilesAndCourses(
|
|
|
|
|
profileIds: string[],
|
|
|
|
|
courseIds: string[]
|
|
|
|
|
): Promise<
|
|
|
|
|
Array<{ profileId: string; courseId: string; status: string; score: number | null; completedAt: string | null }>
|
|
|
|
|
> {
|
|
|
|
|
if (profileIds.length === 0 || courseIds.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
profileId: schema.courseCompletionRecord.profileId,
|
|
|
|
|
courseId: schema.courseCompletionRecord.courseId,
|
|
|
|
|
status: schema.courseCompletionRecord.status,
|
|
|
|
|
score: schema.courseCompletionRecord.score,
|
|
|
|
|
completedAt: schema.courseCompletionRecord.completedAt,
|
|
|
|
|
cycleNumber: schema.courseCompletionRecord.cycleNumber
|
|
|
|
|
})
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
inArray(schema.courseCompletionRecord.profileId, profileIds),
|
|
|
|
|
inArray(schema.courseCompletionRecord.courseId, courseIds)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const latest = new Map<string, (typeof rows)[number]>();
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
const key = `${row.profileId}:${row.courseId}`;
|
|
|
|
|
const prev = latest.get(key);
|
|
|
|
|
if (!prev || (row.cycleNumber ?? 0) > (prev.cycleNumber ?? 0)) {
|
|
|
|
|
latest.set(key, row);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...latest.values()].map((row) => ({
|
|
|
|
|
profileId: row.profileId,
|
|
|
|
|
courseId: row.courseId,
|
|
|
|
|
status: row.status,
|
|
|
|
|
score: row.score ?? null,
|
|
|
|
|
completedAt: row.completedAt ?? null
|
|
|
|
|
}));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getLatestCompletionRecordsForProfilesAndCourses error:', error);
|
|
|
|
|
throw new Error(`Failed to load completion records: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fallback completion source for non-compliance courses: a learner is considered
|
|
|
|
|
* "done" with a course when every lesson in the course has a `lesson_completion`
|
|
|
|
|
* row marked completed. Returns set of (profileId, courseId) keys that match.
|
|
|
|
|
*/
|
|
|
|
|
export async function getNonComplianceCourseCompletions(
|
|
|
|
|
profileIds: string[],
|
|
|
|
|
courseIds: string[]
|
|
|
|
|
): Promise<Set<string>> {
|
|
|
|
|
if (profileIds.length === 0 || courseIds.length === 0) return new Set();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
profileId: schema.lessonCompletion.profileId,
|
|
|
|
|
courseId: schema.lesson.courseId,
|
|
|
|
|
totalLessons: sql<number>`COUNT(DISTINCT ${schema.lesson.id})::int`.as('totalLessons'),
|
|
|
|
|
completedLessons: sql<number>`
|
|
|
|
|
COUNT(DISTINCT CASE
|
|
|
|
|
WHEN ${schema.lessonCompletion.isComplete} = true THEN ${schema.lessonCompletion.lessonId}
|
|
|
|
|
ELSE NULL
|
|
|
|
|
END)::int
|
|
|
|
|
`.as('completedLessons')
|
|
|
|
|
})
|
|
|
|
|
.from(schema.lesson)
|
|
|
|
|
.leftJoin(
|
|
|
|
|
schema.lessonCompletion,
|
|
|
|
|
and(
|
|
|
|
|
eq(schema.lessonCompletion.lessonId, schema.lesson.id),
|
|
|
|
|
inArray(schema.lessonCompletion.profileId, profileIds)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.where(inArray(schema.lesson.courseId, courseIds))
|
|
|
|
|
.groupBy(schema.lessonCompletion.profileId, schema.lesson.courseId);
|
|
|
|
|
|
|
|
|
|
const completed = new Set<string>();
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
if (!row.profileId || !row.courseId) continue;
|
|
|
|
|
if (row.totalLessons > 0 && row.completedLessons >= row.totalLessons) {
|
|
|
|
|
completed.add(`${row.profileId}:${row.courseId}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return completed;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getNonComplianceCourseCompletions error:', error);
|
|
|
|
|
throw new Error(`Failed to load lesson completions: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* For score / pass_rate goals: pull the highest score per (profile, course)
|
|
|
|
|
* across compliance records (which already store the assessment score).
|
|
|
|
|
*/
|
|
|
|
|
export async function getMaxScoresForProfilesAndCourses(
|
|
|
|
|
profileIds: string[],
|
|
|
|
|
courseIds: string[]
|
|
|
|
|
): Promise<Map<string, number>> {
|
|
|
|
|
if (profileIds.length === 0 || courseIds.length === 0) return new Map();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
profileId: schema.courseCompletionRecord.profileId,
|
|
|
|
|
courseId: schema.courseCompletionRecord.courseId,
|
|
|
|
|
maxScore: sql<number | null>`MAX(${schema.courseCompletionRecord.score})`.as('maxScore')
|
|
|
|
|
})
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
inArray(schema.courseCompletionRecord.profileId, profileIds),
|
|
|
|
|
inArray(schema.courseCompletionRecord.courseId, courseIds)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.groupBy(schema.courseCompletionRecord.profileId, schema.courseCompletionRecord.courseId);
|
|
|
|
|
|
|
|
|
|
const map = new Map<string, number>();
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
if (row.maxScore == null) continue;
|
|
|
|
|
map.set(`${row.profileId}:${row.courseId}`, Number(row.maxScore));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('getMaxScoresForProfilesAndCourses error:', error);
|
|
|
|
|
throw new Error(`Failed to load scores: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
}
|
|
|
|
|
}
|