566 lines
20 KiB
TypeScript
566 lines
20 KiB
TypeScript
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'}`
|
||
);
|
||
}
|
||
}
|