2026-04-27 11:40:38 +01:00
|
|
|
|
import * as schema from '@db/schema';
|
|
|
|
|
|
|
|
|
|
|
|
import { ROLE } from '@cio/utils/constants';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
TCourse,
|
|
|
|
|
|
TCourseCertificateIssue,
|
|
|
|
|
|
TCourseCompletionNotificationEvent,
|
|
|
|
|
|
TCourseCompletionRecord,
|
|
|
|
|
|
TGroupmember,
|
|
|
|
|
|
TNewCourseCertificateIssue,
|
|
|
|
|
|
TNewCourseCompletionNotificationEvent,
|
|
|
|
|
|
TNewCourseCompletionRecord
|
|
|
|
|
|
} from '@db/types';
|
|
|
|
|
|
import { and, asc, desc, eq, inArray, isNotNull, lte, or, sql } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
|
|
import { db, type DbOrTxClient } from '@db/drizzle';
|
|
|
|
|
|
|
|
|
|
|
|
type CourseMemberProfile = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
fullname: string | null;
|
|
|
|
|
|
username: string | null;
|
|
|
|
|
|
avatarUrl: string | null;
|
|
|
|
|
|
email: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type CourseComplianceCurrentRow = {
|
|
|
|
|
|
member: TGroupmember;
|
|
|
|
|
|
profile: CourseMemberProfile | null;
|
|
|
|
|
|
record: TCourseCompletionRecord | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type CourseComplianceReminderRow = {
|
|
|
|
|
|
course: TCourse;
|
|
|
|
|
|
member: TGroupmember;
|
|
|
|
|
|
profile: CourseMemberProfile | null;
|
|
|
|
|
|
record: TCourseCompletionRecord;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export async function getStudentCourseMembersForCompliance(
|
|
|
|
|
|
courseId: string,
|
|
|
|
|
|
profileIds?: string[]
|
|
|
|
|
|
): Promise<Array<{ member: TGroupmember; profile: CourseMemberProfile | null }>> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const conditions = [
|
|
|
|
|
|
eq(schema.course.id, courseId),
|
|
|
|
|
|
eq(schema.groupmember.roleId, ROLE.STUDENT),
|
|
|
|
|
|
isNotNull(schema.groupmember.profileId)
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
if (profileIds && profileIds.length > 0) {
|
|
|
|
|
|
conditions.push(inArray(schema.groupmember.profileId, profileIds));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
member: schema.groupmember,
|
|
|
|
|
|
profile: {
|
|
|
|
|
|
id: schema.profile.id,
|
|
|
|
|
|
fullname: schema.profile.fullname,
|
|
|
|
|
|
username: schema.profile.username,
|
|
|
|
|
|
avatarUrl: schema.profile.avatarUrl,
|
|
|
|
|
|
email: schema.profile.email
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.groupmember)
|
|
|
|
|
|
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
|
|
|
|
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
|
|
|
|
.where(and(...conditions))
|
|
|
|
|
|
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
|
|
|
|
|
|
|
|
|
|
|
|
return result.map((row) => ({
|
|
|
|
|
|
member: row.member,
|
|
|
|
|
|
profile: row.profile ?? null
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('getStudentCourseMembersForCompliance error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to get student course members for compliance: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getCourseCurrentComplianceRows(courseId: string): Promise<CourseComplianceCurrentRow[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const latestCycles = db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
courseId: schema.courseCompletionRecord.courseId,
|
|
|
|
|
|
profileId: schema.courseCompletionRecord.profileId,
|
|
|
|
|
|
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.where(eq(schema.courseCompletionRecord.courseId, courseId))
|
|
|
|
|
|
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
|
|
|
|
|
|
.as('latest_course_compliance_cycles');
|
|
|
|
|
|
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
member: schema.groupmember,
|
|
|
|
|
|
profile: {
|
|
|
|
|
|
id: schema.profile.id,
|
|
|
|
|
|
fullname: schema.profile.fullname,
|
|
|
|
|
|
username: schema.profile.username,
|
|
|
|
|
|
avatarUrl: schema.profile.avatarUrl,
|
|
|
|
|
|
email: schema.profile.email
|
|
|
|
|
|
},
|
|
|
|
|
|
record: schema.courseCompletionRecord
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.groupmember)
|
|
|
|
|
|
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
|
|
|
|
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
|
|
|
|
.leftJoin(
|
|
|
|
|
|
latestCycles,
|
|
|
|
|
|
and(eq(latestCycles.courseId, schema.course.id), eq(latestCycles.profileId, schema.groupmember.profileId))
|
|
|
|
|
|
)
|
|
|
|
|
|
.leftJoin(
|
|
|
|
|
|
schema.courseCompletionRecord,
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.courseCompletionRecord.courseId, schema.course.id),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.profileId, schema.groupmember.profileId),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.cycleNumber, latestCycles.latestCycleNumber)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.course.id, courseId),
|
|
|
|
|
|
eq(schema.groupmember.roleId, ROLE.STUDENT),
|
|
|
|
|
|
isNotNull(schema.groupmember.profileId)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
|
|
|
|
|
|
|
|
|
|
|
|
return result.map((row) => ({
|
|
|
|
|
|
member: row.member,
|
|
|
|
|
|
profile: row.profile ?? null,
|
|
|
|
|
|
record: row.record ?? null
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('getCourseCurrentComplianceRows error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to get course current compliance rows: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getCourseComplianceHistoryRows(
|
|
|
|
|
|
courseId: string,
|
|
|
|
|
|
profileId: string
|
|
|
|
|
|
): Promise<{
|
|
|
|
|
|
member: TGroupmember | null;
|
|
|
|
|
|
profile: CourseMemberProfile | null;
|
|
|
|
|
|
records: TCourseCompletionRecord[];
|
|
|
|
|
|
}> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [memberRow, records] = await Promise.all([
|
|
|
|
|
|
db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
member: schema.groupmember,
|
|
|
|
|
|
profile: {
|
|
|
|
|
|
id: schema.profile.id,
|
|
|
|
|
|
fullname: schema.profile.fullname,
|
|
|
|
|
|
username: schema.profile.username,
|
|
|
|
|
|
avatarUrl: schema.profile.avatarUrl,
|
|
|
|
|
|
email: schema.profile.email
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.groupmember)
|
|
|
|
|
|
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
|
|
|
|
|
|
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.course.id, courseId),
|
|
|
|
|
|
eq(schema.groupmember.roleId, ROLE.STUDENT),
|
|
|
|
|
|
eq(schema.groupmember.profileId, profileId)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.limit(1),
|
|
|
|
|
|
db
|
|
|
|
|
|
.select()
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.courseCompletionRecord.courseId, courseId),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.profileId, profileId)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.orderBy(desc(schema.courseCompletionRecord.cycleNumber))
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
member: memberRow[0]?.member ?? null,
|
|
|
|
|
|
profile: memberRow[0]?.profile ?? null,
|
|
|
|
|
|
records
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('getCourseComplianceHistoryRows error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to get course compliance history rows: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getLatestComplianceRecordsByProfiles(
|
|
|
|
|
|
courseId: string,
|
|
|
|
|
|
profileIds: string[]
|
|
|
|
|
|
): Promise<TCourseCompletionRecord[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (profileIds.length === 0) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const latestCycles = db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
courseId: schema.courseCompletionRecord.courseId,
|
|
|
|
|
|
profileId: schema.courseCompletionRecord.profileId,
|
|
|
|
|
|
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.courseCompletionRecord.courseId, courseId),
|
|
|
|
|
|
inArray(schema.courseCompletionRecord.profileId, profileIds)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
|
|
|
|
|
|
.as('latest_course_compliance_cycles_by_profile');
|
|
|
|
|
|
|
|
|
|
|
|
return await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
id: schema.courseCompletionRecord.id,
|
|
|
|
|
|
courseId: schema.courseCompletionRecord.courseId,
|
|
|
|
|
|
groupMemberId: schema.courseCompletionRecord.groupMemberId,
|
|
|
|
|
|
profileId: schema.courseCompletionRecord.profileId,
|
|
|
|
|
|
cycleNumber: schema.courseCompletionRecord.cycleNumber,
|
|
|
|
|
|
status: schema.courseCompletionRecord.status,
|
|
|
|
|
|
dueDate: schema.courseCompletionRecord.dueDate,
|
|
|
|
|
|
startedAt: schema.courseCompletionRecord.startedAt,
|
|
|
|
|
|
completedAt: schema.courseCompletionRecord.completedAt,
|
|
|
|
|
|
validUntil: schema.courseCompletionRecord.validUntil,
|
|
|
|
|
|
expiredAt: schema.courseCompletionRecord.expiredAt,
|
|
|
|
|
|
score: schema.courseCompletionRecord.score,
|
|
|
|
|
|
attempts: schema.courseCompletionRecord.attempts,
|
|
|
|
|
|
timeSpentMinutes: schema.courseCompletionRecord.timeSpentMinutes,
|
|
|
|
|
|
waivedBy: schema.courseCompletionRecord.waivedBy,
|
|
|
|
|
|
waiverReason: schema.courseCompletionRecord.waiverReason,
|
|
|
|
|
|
waiverExpiresAt: schema.courseCompletionRecord.waiverExpiresAt,
|
|
|
|
|
|
createdAt: schema.courseCompletionRecord.createdAt,
|
|
|
|
|
|
updatedAt: schema.courseCompletionRecord.updatedAt
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.innerJoin(
|
|
|
|
|
|
latestCycles,
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
|
|
|
|
|
|
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
|
|
|
|
|
|
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('getLatestComplianceRecordsByProfiles error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to get latest compliance records by profiles: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function createCourseCompletionRecord(
|
|
|
|
|
|
values: TNewCourseCompletionRecord,
|
|
|
|
|
|
dbClient: DbOrTxClient = db
|
|
|
|
|
|
): Promise<TCourseCompletionRecord> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [created] = await dbClient.insert(schema.courseCompletionRecord).values(values).returning();
|
|
|
|
|
|
|
|
|
|
|
|
if (!created) {
|
|
|
|
|
|
throw new Error('Failed to create course completion record');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return created;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('createCourseCompletionRecord error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to create course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function updateCourseCompletionRecord(
|
|
|
|
|
|
recordId: string,
|
|
|
|
|
|
values: Partial<TNewCourseCompletionRecord>,
|
|
|
|
|
|
dbClient: DbOrTxClient = db
|
|
|
|
|
|
): Promise<TCourseCompletionRecord | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [updated] = await dbClient
|
|
|
|
|
|
.update(schema.courseCompletionRecord)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
...values,
|
|
|
|
|
|
updatedAt: new Date().toISOString()
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.courseCompletionRecord.id, recordId))
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
|
|
return updated ?? null;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('updateCourseCompletionRecord error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to update course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function createCourseCompletionNotificationEvent(
|
|
|
|
|
|
values: TNewCourseCompletionNotificationEvent,
|
|
|
|
|
|
dbClient: DbOrTxClient = db
|
|
|
|
|
|
): Promise<TCourseCompletionNotificationEvent | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [created] = await dbClient
|
|
|
|
|
|
.insert(schema.courseCompletionNotificationEvent)
|
|
|
|
|
|
.values(values)
|
|
|
|
|
|
.onConflictDoNothing({
|
|
|
|
|
|
target: [
|
|
|
|
|
|
schema.courseCompletionNotificationEvent.courseCompletionRecordId,
|
|
|
|
|
|
schema.courseCompletionNotificationEvent.channel,
|
|
|
|
|
|
schema.courseCompletionNotificationEvent.eventType
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
|
|
return created ?? null;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('createCourseCompletionNotificationEvent error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to create course completion notification event: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function createCourseCertificateIssue(
|
|
|
|
|
|
values: TNewCourseCertificateIssue,
|
|
|
|
|
|
dbClient: DbOrTxClient = db
|
|
|
|
|
|
): Promise<TCourseCertificateIssue | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [created] = await dbClient
|
|
|
|
|
|
.insert(schema.courseCertificateIssue)
|
|
|
|
|
|
.values(values)
|
|
|
|
|
|
.onConflictDoNothing({
|
|
|
|
|
|
target: [schema.courseCertificateIssue.courseCompletionRecordId]
|
|
|
|
|
|
})
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
|
|
return created ?? null;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('createCourseCertificateIssue error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to create course certificate issue: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function updateCourseCertificateIssueStatusByRecordId(
|
|
|
|
|
|
courseCompletionRecordId: string,
|
|
|
|
|
|
status: string,
|
|
|
|
|
|
dbClient: DbOrTxClient = db
|
|
|
|
|
|
): Promise<TCourseCertificateIssue | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [updated] = await dbClient
|
|
|
|
|
|
.update(schema.courseCertificateIssue)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
status
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.courseCertificateIssue.courseCompletionRecordId, courseCompletionRecordId))
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
|
|
return updated ?? null;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('updateCourseCertificateIssueStatusByRecordId error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to update course certificate issue status by record ID: ${
|
|
|
|
|
|
error instanceof Error ? error.message : 'Unknown error'
|
|
|
|
|
|
}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function listComplianceRecordsReadyForExpiry(nowIso: string): Promise<TCourseCompletionRecord[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
record: schema.courseCompletionRecord
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.course.type, 'COMPLIANCE'),
|
|
|
|
|
|
isNotNull(schema.courseCompletionRecord.completedAt),
|
|
|
|
|
|
isNotNull(schema.courseCompletionRecord.validUntil),
|
|
|
|
|
|
lte(schema.courseCompletionRecord.validUntil, nowIso),
|
|
|
|
|
|
sql`${schema.courseCompletionRecord.expiredAt} IS NULL`
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.orderBy(asc(schema.courseCompletionRecord.validUntil))
|
|
|
|
|
|
.then((rows) => rows.map((row) => row.record));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('listComplianceRecordsReadyForExpiry error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to list compliance records ready for expiry: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function listLatestComplianceRecordsForReminderScan(): Promise<CourseComplianceReminderRow[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const latestCycles = db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
courseId: schema.courseCompletionRecord.courseId,
|
|
|
|
|
|
profileId: schema.courseCompletionRecord.profileId,
|
|
|
|
|
|
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
|
|
|
|
|
|
.as('latest_course_compliance_cycles');
|
|
|
|
|
|
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
course: schema.course,
|
|
|
|
|
|
member: schema.groupmember,
|
|
|
|
|
|
profile: {
|
|
|
|
|
|
id: schema.profile.id,
|
|
|
|
|
|
fullname: schema.profile.fullname,
|
|
|
|
|
|
username: schema.profile.username,
|
|
|
|
|
|
avatarUrl: schema.profile.avatarUrl,
|
|
|
|
|
|
email: schema.profile.email
|
|
|
|
|
|
},
|
|
|
|
|
|
record: schema.courseCompletionRecord
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.innerJoin(
|
|
|
|
|
|
latestCycles,
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
|
|
|
|
|
|
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
|
|
|
|
|
|
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
|
|
|
|
|
|
.innerJoin(schema.groupmember, eq(schema.groupmember.id, schema.courseCompletionRecord.groupMemberId))
|
|
|
|
|
|
.leftJoin(schema.profile, eq(schema.profile.id, schema.courseCompletionRecord.profileId))
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.course.type, 'COMPLIANCE'),
|
|
|
|
|
|
or(
|
|
|
|
|
|
eq(schema.courseCompletionRecord.status, 'not_started'),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.status, 'in_progress'),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.status, 'expiring_soon'),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.status, 'in_grace_period'),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.status, 'non_compliant')
|
|
|
|
|
|
),
|
|
|
|
|
|
sql`${schema.courseCompletionRecord.completedAt} IS NULL`
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.orderBy(asc(schema.courseCompletionRecord.dueDate));
|
|
|
|
|
|
|
|
|
|
|
|
return result.map((row) => ({
|
|
|
|
|
|
course: row.course,
|
|
|
|
|
|
member: row.member,
|
|
|
|
|
|
profile: row.profile ?? null,
|
|
|
|
|
|
record: row.record
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('listLatestComplianceRecordsForReminderScan error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to list latest compliance records for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 03:47:22 +01:00
|
|
|
|
|
|
|
|
|
|
export type OrgComplianceLearnerRow = {
|
|
|
|
|
|
courseId: string;
|
|
|
|
|
|
courseTitle: string;
|
|
|
|
|
|
groupMemberId: string;
|
|
|
|
|
|
profileId: string | null;
|
|
|
|
|
|
profile: CourseMemberProfile | null;
|
|
|
|
|
|
email: string | null;
|
|
|
|
|
|
status: TCourseCompletionRecord['status'] | 'no_record';
|
|
|
|
|
|
cycleNumber: number | null;
|
|
|
|
|
|
dueDate: string | null;
|
|
|
|
|
|
completedAt: string | null;
|
|
|
|
|
|
validUntil: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Latest compliance record per (course, learner) for every COMPLIANCE-type
|
|
|
|
|
|
* course in the org. One row per enrolled student × course.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function getOrgComplianceLearnerRows(orgId: string): Promise<OrgComplianceLearnerRow[]> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const latestCycles = db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
courseId: schema.courseCompletionRecord.courseId,
|
|
|
|
|
|
profileId: schema.courseCompletionRecord.profileId,
|
|
|
|
|
|
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.courseCompletionRecord)
|
|
|
|
|
|
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
|
|
|
|
|
|
.as('latest_org_compliance_cycles');
|
|
|
|
|
|
|
|
|
|
|
|
const result = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
courseId: schema.course.id,
|
|
|
|
|
|
courseTitle: schema.course.title,
|
|
|
|
|
|
member: schema.groupmember,
|
|
|
|
|
|
profile: {
|
|
|
|
|
|
id: schema.profile.id,
|
|
|
|
|
|
fullname: schema.profile.fullname,
|
|
|
|
|
|
username: schema.profile.username,
|
|
|
|
|
|
avatarUrl: schema.profile.avatarUrl,
|
|
|
|
|
|
email: schema.profile.email
|
|
|
|
|
|
},
|
|
|
|
|
|
record: schema.courseCompletionRecord
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(schema.course)
|
|
|
|
|
|
.innerJoin(schema.group, eq(schema.group.id, schema.course.groupId))
|
|
|
|
|
|
.innerJoin(schema.groupmember, eq(schema.groupmember.groupId, schema.group.id))
|
|
|
|
|
|
.leftJoin(schema.profile, eq(schema.profile.id, schema.groupmember.profileId))
|
|
|
|
|
|
.leftJoin(
|
|
|
|
|
|
latestCycles,
|
|
|
|
|
|
and(eq(latestCycles.courseId, schema.course.id), eq(latestCycles.profileId, schema.groupmember.profileId))
|
|
|
|
|
|
)
|
|
|
|
|
|
.leftJoin(
|
|
|
|
|
|
schema.courseCompletionRecord,
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.courseCompletionRecord.courseId, schema.course.id),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.profileId, schema.groupmember.profileId),
|
|
|
|
|
|
eq(schema.courseCompletionRecord.cycleNumber, latestCycles.latestCycleNumber)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(schema.group.organizationId, orgId),
|
|
|
|
|
|
eq(schema.course.type, 'COMPLIANCE'),
|
|
|
|
|
|
eq(schema.groupmember.roleId, ROLE.STUDENT),
|
|
|
|
|
|
isNotNull(schema.groupmember.profileId)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
.orderBy(asc(schema.course.title), asc(schema.profile.fullname));
|
|
|
|
|
|
|
|
|
|
|
|
return result.map((row) => ({
|
|
|
|
|
|
courseId: row.courseId,
|
|
|
|
|
|
courseTitle: row.courseTitle,
|
|
|
|
|
|
groupMemberId: row.member.id,
|
|
|
|
|
|
profileId: row.member.profileId,
|
|
|
|
|
|
profile: row.profile ?? null,
|
|
|
|
|
|
email: row.profile?.email ?? row.member.email ?? null,
|
|
|
|
|
|
status: (row.record?.status as TCourseCompletionRecord['status'] | undefined) ?? 'no_record',
|
|
|
|
|
|
cycleNumber: row.record?.cycleNumber ?? null,
|
|
|
|
|
|
dueDate: row.record?.dueDate ?? null,
|
|
|
|
|
|
completedAt: row.record?.completedAt ?? null,
|
|
|
|
|
|
validUntil: row.record?.validUntil ?? null
|
|
|
|
|
|
}));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('getOrgComplianceLearnerRows error:', error);
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Failed to get org compliance learner rows: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|