Files
classroomio/packages/db/src/queries/course/compliance.ts
T

566 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'}`
);
}
}
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'}`
);
}
}