740 lines
23 KiB
TypeScript
740 lines
23 KiB
TypeScript
import {
|
||
and,
|
||
course,
|
||
courseCompletionRecord,
|
||
courseSection,
|
||
db,
|
||
eq,
|
||
exercise,
|
||
group,
|
||
groupmember,
|
||
inArray,
|
||
lesson,
|
||
organizationmember,
|
||
profile,
|
||
user
|
||
} from '@db/drizzle';
|
||
|
||
import type {
|
||
TNewCourse,
|
||
TNewCourseCompletionRecord,
|
||
TNewCourseSection,
|
||
TNewExercise,
|
||
TNewGroup,
|
||
TNewGroupmember,
|
||
TNewLesson,
|
||
TNewProfile
|
||
} from '@db/types';
|
||
|
||
interface SeedComplianceArgs {
|
||
enterpriseOrgId: string;
|
||
}
|
||
|
||
// ---------- Fixed IDs (stable across runs) ----------
|
||
|
||
const HIPAA_GROUP_ID = '7e000000-1000-4000-8000-000000000001';
|
||
const SOC2_GROUP_ID = '7e000000-1000-4000-8000-000000000002';
|
||
|
||
const HIPAA_COURSE_ID = '7e000000-2000-4000-8000-000000000001';
|
||
const SOC2_COURSE_ID = '7e000000-2000-4000-8000-000000000002';
|
||
|
||
const HIPAA_SECTION_ID = '7e000000-3000-4000-8000-000000000001';
|
||
const SOC2_SECTION_ID = '7e000000-3000-4000-8000-000000000002';
|
||
|
||
const HIPAA_LESSON_IDS = ['7e000000-4000-4000-8000-000000000001', '7e000000-4000-4000-8000-000000000002'] as const;
|
||
const SOC2_LESSON_IDS = ['7e000000-4000-4000-8000-000000000003', '7e000000-4000-4000-8000-000000000004'] as const;
|
||
|
||
const HIPAA_EXERCISE_ID = '7e000000-5000-4000-8000-000000000001';
|
||
const SOC2_EXERCISE_ID = '7e000000-5000-4000-8000-000000000002';
|
||
|
||
// 20 stable learner profile UUIDs
|
||
const LEARNER_PROFILES: Array<{ id: string; fullname: string; username: string; email: string; avatarUrl: string }> = [
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000001',
|
||
fullname: 'Ada Lovelace',
|
||
username: 'ada.lovelace',
|
||
email: 'ada.lovelace@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000002',
|
||
fullname: 'Grace Hopper',
|
||
username: 'grace.hopper',
|
||
email: 'grace.hopper@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000003',
|
||
fullname: 'Linus Torvalds',
|
||
username: 'linus.torvalds',
|
||
email: 'linus.torvalds@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000004',
|
||
fullname: 'Margaret Hamilton',
|
||
username: 'margaret.hamilton',
|
||
email: 'margaret.hamilton@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000005',
|
||
fullname: 'Alan Turing',
|
||
username: 'alan.turing',
|
||
email: 'alan.turing@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000006',
|
||
fullname: 'Katherine Johnson',
|
||
username: 'katherine.johnson',
|
||
email: 'katherine.johnson@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000007',
|
||
fullname: 'Dennis Ritchie',
|
||
username: 'dennis.ritchie',
|
||
email: 'dennis.ritchie@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000008',
|
||
fullname: 'Barbara Liskov',
|
||
username: 'barbara.liskov',
|
||
email: 'barbara.liskov@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000009',
|
||
fullname: 'Donald Knuth',
|
||
username: 'donald.knuth',
|
||
email: 'donald.knuth@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000010',
|
||
fullname: 'Radia Perlman',
|
||
username: 'radia.perlman',
|
||
email: 'radia.perlman@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000011',
|
||
fullname: 'Tim Berners-Lee',
|
||
username: 'tim.bernerslee',
|
||
email: 'tim.bernerslee@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000012',
|
||
fullname: 'Sophie Wilson',
|
||
username: 'sophie.wilson',
|
||
email: 'sophie.wilson@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000013',
|
||
fullname: 'Anita Borg',
|
||
username: 'anita.borg',
|
||
email: 'anita.borg@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000014',
|
||
fullname: 'Brendan Eich',
|
||
username: 'brendan.eich',
|
||
email: 'brendan.eich@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000015',
|
||
fullname: 'Jeannette Wing',
|
||
username: 'jeannette.wing',
|
||
email: 'jeannette.wing@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000016',
|
||
fullname: 'Bjarne Stroustrup',
|
||
username: 'bjarne.stroustrup',
|
||
email: 'bjarne.stroustrup@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000017',
|
||
fullname: 'Hedy Lamarr',
|
||
username: 'hedy.lamarr',
|
||
email: 'hedy.lamarr@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000018',
|
||
fullname: 'James Gosling',
|
||
username: 'james.gosling',
|
||
email: 'james.gosling@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000019',
|
||
fullname: 'Frances Allen',
|
||
username: 'frances.allen',
|
||
email: 'frances.allen@coursera-test.demo'
|
||
},
|
||
{
|
||
id: '7e000000-9000-4000-8000-000000000020',
|
||
fullname: 'Vint Cerf',
|
||
username: 'vint.cerf',
|
||
email: 'vint.cerf@coursera-test.demo'
|
||
}
|
||
].map((p) => ({
|
||
...p,
|
||
avatarUrl: `https://api.dicebear.com/9.x/notionists/svg?seed=${encodeURIComponent(p.username)}`
|
||
}));
|
||
|
||
// ---------- Helpers ----------
|
||
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
|
||
function isoDaysFromNow(now: Date, days: number): string {
|
||
return new Date(now.getTime() + days * DAY_MS).toISOString();
|
||
}
|
||
|
||
type RecordPlan = {
|
||
status:
|
||
| 'compliant'
|
||
| 'expiring_soon'
|
||
| 'in_grace_period'
|
||
| 'non_compliant'
|
||
| 'in_progress'
|
||
| 'not_started'
|
||
| 'waived'
|
||
| 'no_record';
|
||
};
|
||
|
||
// Per-course learner-index → status plan. Indices reference LEARNER_PROFILES (0..19).
|
||
const HIPAA_STATUS_PLAN: ReadonlyArray<RecordPlan['status']> = [
|
||
// 9 compliant
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
// 2 expiring_soon
|
||
'expiring_soon',
|
||
'expiring_soon',
|
||
// 1 in_grace_period
|
||
'in_grace_period',
|
||
// 2 non_compliant
|
||
'non_compliant',
|
||
'non_compliant',
|
||
// 2 in_progress
|
||
'in_progress',
|
||
'in_progress',
|
||
// 2 not_started
|
||
'not_started',
|
||
'not_started',
|
||
// 1 waived
|
||
'waived',
|
||
// 1 no_record (no row inserted)
|
||
'no_record'
|
||
];
|
||
|
||
const SOC2_STATUS_PLAN: ReadonlyArray<RecordPlan['status']> = [
|
||
// 7 compliant
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
'compliant',
|
||
// 2 expiring_soon
|
||
'expiring_soon',
|
||
'expiring_soon',
|
||
// 2 in_grace_period
|
||
'in_grace_period',
|
||
'in_grace_period',
|
||
// 3 non_compliant
|
||
'non_compliant',
|
||
'non_compliant',
|
||
'non_compliant',
|
||
// 2 in_progress
|
||
'in_progress',
|
||
'in_progress',
|
||
// 2 not_started
|
||
'not_started',
|
||
'not_started',
|
||
// 1 waived
|
||
'waived',
|
||
// 1 no_record
|
||
'no_record'
|
||
];
|
||
|
||
function buildCompletionRecord({
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
status,
|
||
now,
|
||
retakeIntervalMonths,
|
||
waiverAdminProfileId
|
||
}: {
|
||
courseId: string;
|
||
groupMemberId: string;
|
||
profileId: string;
|
||
status: RecordPlan['status'];
|
||
now: Date;
|
||
retakeIntervalMonths: number;
|
||
waiverAdminProfileId: string | null;
|
||
}): TNewCourseCompletionRecord | null {
|
||
if (status === 'no_record') return null;
|
||
|
||
const validityDays = retakeIntervalMonths * 30;
|
||
|
||
switch (status) {
|
||
case 'compliant': {
|
||
// Completed ~ between (validityDays - 90) and (validityDays - 30) days ago,
|
||
// so validUntil is well in the future (>30 days away).
|
||
const completedDaysAgo = Math.max(30, Math.floor(validityDays / 3));
|
||
const completedAt = isoDaysFromNow(now, -completedDaysAgo);
|
||
const validUntil = isoDaysFromNow(now, validityDays - completedDaysAgo);
|
||
return {
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
cycleNumber: 1,
|
||
status,
|
||
dueDate: completedAt,
|
||
startedAt: isoDaysFromNow(now, -completedDaysAgo - 2),
|
||
completedAt,
|
||
validUntil,
|
||
score: 88,
|
||
attempts: 1,
|
||
timeSpentMinutes: 42
|
||
};
|
||
}
|
||
case 'expiring_soon': {
|
||
// completedAt ~ (validityDays - 7) days ago → validUntil 7 days from now.
|
||
const completedDaysAgo = validityDays - 7;
|
||
const completedAt = isoDaysFromNow(now, -completedDaysAgo);
|
||
const validUntil = isoDaysFromNow(now, 7);
|
||
return {
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
cycleNumber: 1,
|
||
status,
|
||
dueDate: completedAt,
|
||
startedAt: isoDaysFromNow(now, -completedDaysAgo - 1),
|
||
completedAt,
|
||
validUntil,
|
||
score: 91,
|
||
attempts: 1,
|
||
timeSpentMinutes: 38
|
||
};
|
||
}
|
||
case 'in_grace_period': {
|
||
// dueDate 3 days in the past, never completed.
|
||
return {
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
cycleNumber: 1,
|
||
status,
|
||
dueDate: isoDaysFromNow(now, -3),
|
||
startedAt: isoDaysFromNow(now, -10),
|
||
attempts: 0
|
||
};
|
||
}
|
||
case 'non_compliant': {
|
||
// dueDate 30 days in the past, never completed.
|
||
return {
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
cycleNumber: 1,
|
||
status,
|
||
dueDate: isoDaysFromNow(now, -30),
|
||
startedAt: isoDaysFromNow(now, -20),
|
||
attempts: 0
|
||
};
|
||
}
|
||
case 'in_progress': {
|
||
return {
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
cycleNumber: 1,
|
||
status,
|
||
dueDate: isoDaysFromNow(now, 21),
|
||
startedAt: isoDaysFromNow(now, -2),
|
||
attempts: 0,
|
||
timeSpentMinutes: 12
|
||
};
|
||
}
|
||
case 'not_started': {
|
||
return {
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
cycleNumber: 1,
|
||
status,
|
||
dueDate: isoDaysFromNow(now, 21),
|
||
attempts: 0
|
||
};
|
||
}
|
||
case 'waived': {
|
||
return {
|
||
courseId,
|
||
groupMemberId,
|
||
profileId,
|
||
cycleNumber: 1,
|
||
status,
|
||
dueDate: isoDaysFromNow(now, 14),
|
||
waivedBy: waiverAdminProfileId ?? profileId,
|
||
waiverReason: 'On approved leave through end of cycle',
|
||
waiverExpiresAt: isoDaysFromNow(now, 90)
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Main seed ----------
|
||
|
||
export async function seedCompliance({ enterpriseOrgId }: SeedComplianceArgs) {
|
||
const now = new Date();
|
||
|
||
// 1a. Users (profile.id FKs to user.id)
|
||
const profileIds = LEARNER_PROFILES.map((p) => p.id);
|
||
const existingUsers = await db.select({ id: user.id }).from(user).where(inArray(user.id, profileIds));
|
||
const existingUserIds = new Set(existingUsers.map((u) => u.id));
|
||
const usersToInsert = LEARNER_PROFILES.filter((p) => !existingUserIds.has(p.id)).map((p) => ({
|
||
id: p.id,
|
||
name: p.fullname,
|
||
email: p.email,
|
||
emailVerified: true,
|
||
image: null,
|
||
role: null,
|
||
banned: false,
|
||
isAnonymous: false
|
||
}));
|
||
if (usersToInsert.length > 0) {
|
||
await db.insert(user).values(usersToInsert);
|
||
console.log(` ✓ Inserted ${usersToInsert.length} learner user(s)`);
|
||
} else {
|
||
console.log(' ✓ Learner users already exist, skipping');
|
||
}
|
||
|
||
// 1b. Profiles
|
||
const existingProfiles = await db.select({ id: profile.id }).from(profile).where(inArray(profile.id, profileIds));
|
||
const existingProfileIds = new Set(existingProfiles.map((p) => p.id));
|
||
const profilesToInsert: TNewProfile[] = LEARNER_PROFILES.filter((p) => !existingProfileIds.has(p.id)).map((p) => ({
|
||
id: p.id,
|
||
fullname: p.fullname,
|
||
username: p.username,
|
||
email: p.email,
|
||
avatarUrl: p.avatarUrl,
|
||
canAddCourse: false,
|
||
isEmailVerified: true
|
||
}));
|
||
if (profilesToInsert.length > 0) {
|
||
await db.insert(profile).values(profilesToInsert);
|
||
console.log(` ✓ Inserted ${profilesToInsert.length} learner profile(s)`);
|
||
} else {
|
||
console.log(' ✓ Learner profiles already exist, skipping');
|
||
}
|
||
|
||
// 2. Groups
|
||
const existingGroups = await db
|
||
.select({ id: group.id })
|
||
.from(group)
|
||
.where(inArray(group.id, [HIPAA_GROUP_ID, SOC2_GROUP_ID]));
|
||
const existingGroupIds = new Set(existingGroups.map((g) => g.id));
|
||
const groupsToInsert: TNewGroup[] = [
|
||
{
|
||
id: HIPAA_GROUP_ID,
|
||
name: 'HIPAA Awareness 2026',
|
||
description: 'Mandatory annual HIPAA awareness refresher for all Coursera Test staff handling PHI.',
|
||
organizationId: enterpriseOrgId
|
||
},
|
||
{
|
||
id: SOC2_GROUP_ID,
|
||
name: 'SOC 2 Security Basics',
|
||
description: 'Twice-yearly SOC 2 security fundamentals — required for engineering and operations.',
|
||
organizationId: enterpriseOrgId
|
||
}
|
||
].filter((g) => !existingGroupIds.has(g.id));
|
||
if (groupsToInsert.length > 0) {
|
||
await db.insert(group).values(groupsToInsert);
|
||
console.log(` ✓ Inserted ${groupsToInsert.length} compliance group(s)`);
|
||
} else {
|
||
console.log(' ✓ Compliance groups already exist, skipping');
|
||
}
|
||
|
||
// 3. Courses (COMPLIANCE type)
|
||
const existingCourses = await db
|
||
.select({ id: course.id })
|
||
.from(course)
|
||
.where(inArray(course.id, [HIPAA_COURSE_ID, SOC2_COURSE_ID]));
|
||
const existingCourseIds = new Set(existingCourses.map((c) => c.id));
|
||
const allComplianceCourses: TNewCourse[] = [
|
||
{
|
||
id: HIPAA_COURSE_ID,
|
||
title: 'HIPAA Awareness 2026',
|
||
description:
|
||
'Annual HIPAA refresher covering PHI handling, breach reporting, and minimum-necessary access for everyone who touches patient data.',
|
||
overview: '<p>Annual HIPAA refresher covering PHI handling, breach reporting, and minimum-necessary access.</p>',
|
||
groupId: HIPAA_GROUP_ID,
|
||
isTemplate: false,
|
||
logo: 'https://images.unsplash.com/photo-1551601651-2a8555f1a136?auto=format&fit=crop&w=600&q=70',
|
||
slug: 'hipaa-awareness-2026',
|
||
metadata: {
|
||
goals: '',
|
||
grading: false,
|
||
description: '',
|
||
requirements: '',
|
||
lessonDownload: false,
|
||
allowNewStudent: false
|
||
},
|
||
cost: 0,
|
||
currency: 'USD',
|
||
isPublished: true,
|
||
certificate: { isDownloadable: true, theme: 'professional' },
|
||
compliance: {
|
||
retakeIntervalMonths: 12,
|
||
gracePeriodDays: 14,
|
||
reminderDaysBefore: [30, 14, 7, 1],
|
||
isMandatory: true,
|
||
framework: 'HIPAA',
|
||
passingScore: 80
|
||
},
|
||
status: 'ACTIVE',
|
||
type: 'COMPLIANCE'
|
||
},
|
||
{
|
||
id: SOC2_COURSE_ID,
|
||
title: 'SOC 2 Security Basics',
|
||
description:
|
||
'Core SOC 2 controls — password hygiene, phishing, data classification, incident response. Runs every six months for engineering and operations.',
|
||
overview: '<p>Twice-yearly SOC 2 security fundamentals. Required for engineering, IT, and operations.</p>',
|
||
groupId: SOC2_GROUP_ID,
|
||
isTemplate: false,
|
||
logo: 'https://images.unsplash.com/photo-1614064641938-3bbee52942c7?auto=format&fit=crop&w=600&q=70',
|
||
slug: 'soc2-security-basics',
|
||
metadata: {
|
||
goals: '',
|
||
grading: true,
|
||
description: '',
|
||
requirements: '',
|
||
lessonDownload: false,
|
||
allowNewStudent: false
|
||
},
|
||
cost: 0,
|
||
currency: 'USD',
|
||
isPublished: true,
|
||
certificate: { isDownloadable: true, theme: 'professional' },
|
||
compliance: {
|
||
retakeIntervalMonths: 6,
|
||
gracePeriodDays: 7,
|
||
reminderDaysBefore: [14, 7, 1],
|
||
isMandatory: true,
|
||
framework: 'ISO',
|
||
passingScore: 85
|
||
},
|
||
status: 'ACTIVE',
|
||
type: 'COMPLIANCE'
|
||
}
|
||
];
|
||
const coursesToInsert = allComplianceCourses.filter((c) => !existingCourseIds.has(c.id!));
|
||
if (coursesToInsert.length > 0) {
|
||
await db.insert(course).values(coursesToInsert);
|
||
console.log(` ✓ Inserted ${coursesToInsert.length} compliance course(s)`);
|
||
} else {
|
||
console.log(' ✓ Compliance courses already exist, skipping');
|
||
}
|
||
|
||
// 4. Sections + lessons + exercises
|
||
const sectionsToInsert: TNewCourseSection[] = [
|
||
{ id: HIPAA_SECTION_ID, courseId: HIPAA_COURSE_ID, title: 'Compliance Module', order: 0 },
|
||
{ id: SOC2_SECTION_ID, courseId: SOC2_COURSE_ID, title: 'Security Fundamentals', order: 0 }
|
||
];
|
||
const existingSections = await db
|
||
.select({ id: courseSection.id })
|
||
.from(courseSection)
|
||
.where(inArray(courseSection.id, [HIPAA_SECTION_ID, SOC2_SECTION_ID]));
|
||
const existingSectionIds = new Set(existingSections.map((s) => s.id));
|
||
const newSections = sectionsToInsert.filter((s) => !existingSectionIds.has(s.id!));
|
||
if (newSections.length > 0) {
|
||
await db.insert(courseSection).values(newSections);
|
||
console.log(` ✓ Inserted ${newSections.length} compliance section(s)`);
|
||
} else {
|
||
console.log(' ✓ Compliance sections already exist, skipping');
|
||
}
|
||
|
||
const lessonsPlan: TNewLesson[] = [
|
||
{
|
||
id: HIPAA_LESSON_IDS[0],
|
||
courseId: HIPAA_COURSE_ID,
|
||
sectionId: HIPAA_SECTION_ID,
|
||
title: 'What HIPAA actually requires of you',
|
||
note: '<p>Plain-English summary of the privacy rule, security rule, and breach notification obligations.</p>',
|
||
isUnlocked: true
|
||
},
|
||
{
|
||
id: HIPAA_LESSON_IDS[1],
|
||
courseId: HIPAA_COURSE_ID,
|
||
sectionId: HIPAA_SECTION_ID,
|
||
title: 'Handling PHI: the everyday rules',
|
||
note: '<p>Minimum necessary access, secure messaging, faxing rules, and what to do if you spot a breach.</p>',
|
||
isUnlocked: false
|
||
},
|
||
{
|
||
id: SOC2_LESSON_IDS[0],
|
||
courseId: SOC2_COURSE_ID,
|
||
sectionId: SOC2_SECTION_ID,
|
||
title: 'Passwords, MFA, and not getting phished',
|
||
note: '<p>Strong-credential hygiene + recognizing the four most common phishing patterns we see at Coursera Test.</p>',
|
||
isUnlocked: true
|
||
},
|
||
{
|
||
id: SOC2_LESSON_IDS[1],
|
||
courseId: SOC2_COURSE_ID,
|
||
sectionId: SOC2_SECTION_ID,
|
||
title: 'Data classification & incident response',
|
||
note: '<p>What counts as confidential, who to call when something goes wrong, and how we record an incident.</p>',
|
||
isUnlocked: false
|
||
}
|
||
];
|
||
const existingLessons = await db
|
||
.select({ id: lesson.id })
|
||
.from(lesson)
|
||
.where(
|
||
inArray(
|
||
lesson.id,
|
||
lessonsPlan.map((l) => l.id!)
|
||
)
|
||
);
|
||
const existingLessonIds = new Set(existingLessons.map((l) => l.id));
|
||
const newLessons = lessonsPlan.filter((l) => !existingLessonIds.has(l.id!));
|
||
if (newLessons.length > 0) {
|
||
await db.insert(lesson).values(newLessons);
|
||
console.log(` ✓ Inserted ${newLessons.length} compliance lesson(s)`);
|
||
} else {
|
||
console.log(' ✓ Compliance lessons already exist, skipping');
|
||
}
|
||
|
||
const exercisesPlan: TNewExercise[] = [
|
||
{
|
||
id: HIPAA_EXERCISE_ID,
|
||
title: 'HIPAA: end-of-course quiz',
|
||
description: '<p>Pass this short quiz to mark the course complete.</p>',
|
||
lessonId: HIPAA_LESSON_IDS[1],
|
||
sectionId: HIPAA_SECTION_ID
|
||
},
|
||
{
|
||
id: SOC2_EXERCISE_ID,
|
||
title: 'SOC 2: end-of-course quiz',
|
||
description: '<p>Pass this short quiz to mark the course complete.</p>',
|
||
lessonId: SOC2_LESSON_IDS[1],
|
||
sectionId: SOC2_SECTION_ID
|
||
}
|
||
];
|
||
const existingExercises = await db
|
||
.select({ id: exercise.id })
|
||
.from(exercise)
|
||
.where(inArray(exercise.id, [HIPAA_EXERCISE_ID, SOC2_EXERCISE_ID]));
|
||
const existingExerciseIds = new Set(existingExercises.map((e) => e.id));
|
||
const newExercises = exercisesPlan.filter((e) => !existingExerciseIds.has(e.id!));
|
||
if (newExercises.length > 0) {
|
||
await db.insert(exercise).values(newExercises);
|
||
console.log(` ✓ Inserted ${newExercises.length} compliance exercise(s)`);
|
||
} else {
|
||
console.log(' ✓ Compliance exercises already exist, skipping');
|
||
}
|
||
|
||
// 5. Groupmembers (20 learners × 2 groups)
|
||
const desiredMembers: Array<{ groupId: string; profileId: string }> = [];
|
||
for (const groupId of [HIPAA_GROUP_ID, SOC2_GROUP_ID]) {
|
||
for (const p of LEARNER_PROFILES) {
|
||
desiredMembers.push({ groupId, profileId: p.id });
|
||
}
|
||
}
|
||
const existingGroupMembers = await db
|
||
.select({ id: groupmember.id, groupId: groupmember.groupId, profileId: groupmember.profileId })
|
||
.from(groupmember)
|
||
.where(inArray(groupmember.groupId, [HIPAA_GROUP_ID, SOC2_GROUP_ID]));
|
||
const existingMemberKey = new Set(existingGroupMembers.map((m) => `${m.groupId}-${m.profileId}`));
|
||
const newMembers: TNewGroupmember[] = desiredMembers
|
||
.filter((m) => !existingMemberKey.has(`${m.groupId}-${m.profileId}`))
|
||
.map((m) => ({
|
||
groupId: m.groupId,
|
||
profileId: m.profileId,
|
||
roleId: 3 // STUDENT
|
||
}));
|
||
if (newMembers.length > 0) {
|
||
await db.insert(groupmember).values(newMembers);
|
||
console.log(` ✓ Inserted ${newMembers.length} compliance groupmember(s)`);
|
||
} else {
|
||
console.log(' ✓ Compliance groupmembers already exist, skipping');
|
||
}
|
||
|
||
// Refetch groupmembers so we can look up the groupMember.id for each (groupId, profileId)
|
||
const allMembers = await db
|
||
.select({ id: groupmember.id, groupId: groupmember.groupId, profileId: groupmember.profileId })
|
||
.from(groupmember)
|
||
.where(inArray(groupmember.groupId, [HIPAA_GROUP_ID, SOC2_GROUP_ID]));
|
||
const memberLookup = new Map<string, string>();
|
||
for (const m of allMembers) {
|
||
if (m.profileId) memberLookup.set(`${m.groupId}-${m.profileId}`, m.id);
|
||
}
|
||
|
||
// 6. Resolve a waiver-admin profileId (the org's first admin)
|
||
const orgAdmins = await db
|
||
.select({ profileId: organizationmember.profileId })
|
||
.from(organizationmember)
|
||
.where(and(eq(organizationmember.organizationId, enterpriseOrgId), eq(organizationmember.roleId, 1)));
|
||
const waiverAdminProfileId = orgAdmins.find((a) => a.profileId)?.profileId ?? null;
|
||
|
||
// 7. course_completion_record rows
|
||
const recordsPlan: Array<{
|
||
courseId: string;
|
||
status: RecordPlan['status'];
|
||
profileIdx: number;
|
||
retakeIntervalMonths: number;
|
||
}> = [];
|
||
HIPAA_STATUS_PLAN.forEach((status, idx) =>
|
||
recordsPlan.push({ courseId: HIPAA_COURSE_ID, status, profileIdx: idx, retakeIntervalMonths: 12 })
|
||
);
|
||
SOC2_STATUS_PLAN.forEach((status, idx) =>
|
||
recordsPlan.push({ courseId: SOC2_COURSE_ID, status, profileIdx: idx, retakeIntervalMonths: 6 })
|
||
);
|
||
|
||
const existingRecords = await db
|
||
.select({
|
||
courseId: courseCompletionRecord.courseId,
|
||
profileId: courseCompletionRecord.profileId,
|
||
cycleNumber: courseCompletionRecord.cycleNumber
|
||
})
|
||
.from(courseCompletionRecord)
|
||
.where(inArray(courseCompletionRecord.courseId, [HIPAA_COURSE_ID, SOC2_COURSE_ID]));
|
||
const existingRecordKey = new Set(existingRecords.map((r) => `${r.courseId}-${r.profileId}-${r.cycleNumber}`));
|
||
|
||
const recordsToInsert: TNewCourseCompletionRecord[] = [];
|
||
for (const plan of recordsPlan) {
|
||
const learner = LEARNER_PROFILES[plan.profileIdx];
|
||
if (!learner) continue;
|
||
|
||
const groupId = plan.courseId === HIPAA_COURSE_ID ? HIPAA_GROUP_ID : SOC2_GROUP_ID;
|
||
const groupMemberId = memberLookup.get(`${groupId}-${learner.id}`);
|
||
if (!groupMemberId) continue;
|
||
|
||
const key = `${plan.courseId}-${learner.id}-1`;
|
||
if (existingRecordKey.has(key)) continue;
|
||
|
||
const row = buildCompletionRecord({
|
||
courseId: plan.courseId,
|
||
groupMemberId,
|
||
profileId: learner.id,
|
||
status: plan.status,
|
||
now,
|
||
retakeIntervalMonths: plan.retakeIntervalMonths,
|
||
waiverAdminProfileId
|
||
});
|
||
if (row) recordsToInsert.push(row);
|
||
}
|
||
|
||
if (recordsToInsert.length > 0) {
|
||
await db.insert(courseCompletionRecord).values(recordsToInsert);
|
||
console.log(` ✓ Inserted ${recordsToInsert.length} course_completion_record row(s)`);
|
||
} else {
|
||
console.log(' ✓ course_completion_record rows already exist, skipping');
|
||
}
|
||
}
|