feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, asc, eq, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
/**
|
||||
* Whether an exercise is part of a course (direct course_id or via lesson).
|
||||
*/
|
||||
export async function exerciseBelongsToCourse(exerciseId: string, courseId: string): Promise<boolean> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ id: schema.exercise.id })
|
||||
.from(schema.exercise)
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.exercise.id, exerciseId),
|
||||
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return !!row;
|
||||
} catch (error) {
|
||||
console.error('exerciseBelongsToCourse error:', error);
|
||||
throw new Error(
|
||||
`Failed to check exercise belongs to course: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExerciseTitleAndMaxPoints(exerciseId: string): Promise<{ title: string; maxPoints: number }> {
|
||||
try {
|
||||
const [ex] = await db
|
||||
.select({ title: schema.exercise.title })
|
||||
.from(schema.exercise)
|
||||
.where(eq(schema.exercise.id, exerciseId))
|
||||
.limit(1);
|
||||
|
||||
if (!ex) {
|
||||
return { title: '', maxPoints: 0 };
|
||||
}
|
||||
|
||||
const [sumRow] = await db
|
||||
.select({
|
||||
maxPoints: sql<number>`coalesce(sum(${schema.question.points}), 0)::float`
|
||||
})
|
||||
.from(schema.question)
|
||||
.where(eq(schema.question.exerciseId, exerciseId));
|
||||
|
||||
return { title: ex.title ?? '', maxPoints: Number(sumRow?.maxPoints ?? 0) };
|
||||
} catch (error) {
|
||||
console.error('getExerciseTitleAndMaxPoints error:', error);
|
||||
throw new Error(
|
||||
`Failed to get exercise title and max points: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudentSubmissionsForExercise(
|
||||
groupMemberId: string,
|
||||
exerciseId: string
|
||||
): Promise<Array<{ total: number | null; gradingState: string | null }>> {
|
||||
try {
|
||||
return db
|
||||
.select({
|
||||
total: schema.submission.total,
|
||||
gradingState: schema.submission.gradingState
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(and(eq(schema.submission.exerciseId, exerciseId), eq(schema.submission.submittedBy, groupMemberId)))
|
||||
.orderBy(asc(schema.submission.createdAt));
|
||||
} catch (error) {
|
||||
console.error('getStudentSubmissionsForExercise error:', error);
|
||||
throw new Error(
|
||||
`Failed to list submissions for exercise: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -426,11 +426,20 @@ export async function getCourseProgress(
|
||||
lessonsCompleted: number;
|
||||
exercisesCount: number;
|
||||
exercisesCompleted: number;
|
||||
groupMemberId: string | null;
|
||||
roleId: number | null;
|
||||
certificateEarnedAt: string | null;
|
||||
certificationEmailSentAt: string | null;
|
||||
}> {
|
||||
try {
|
||||
// Get the groupmember ID for this profile in the course
|
||||
const groupMemberResult = await db
|
||||
.select({ id: schema.groupmember.id })
|
||||
.select({
|
||||
id: schema.groupmember.id,
|
||||
roleId: schema.groupmember.roleId,
|
||||
certificateEarnedAt: schema.groupmember.certificateEarnedAt,
|
||||
certificationEmailSentAt: schema.groupmember.certificationEmailSentAt
|
||||
})
|
||||
.from(schema.groupmember)
|
||||
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
|
||||
.where(and(eq(schema.course.id, courseId), eq(schema.groupmember.profileId, profileId)))
|
||||
@@ -442,64 +451,64 @@ export async function getCourseProgress(
|
||||
lessonsCount: 0,
|
||||
lessonsCompleted: 0,
|
||||
exercisesCount: 0,
|
||||
exercisesCompleted: 0
|
||||
exercisesCompleted: 0,
|
||||
groupMemberId: null,
|
||||
roleId: null,
|
||||
certificateEarnedAt: null,
|
||||
certificationEmailSentAt: null
|
||||
};
|
||||
}
|
||||
|
||||
const groupMemberId = groupMemberResult[0].id;
|
||||
const roleId = groupMemberResult[0].roleId;
|
||||
const certificateEarnedAt = groupMemberResult[0].certificateEarnedAt ?? null;
|
||||
const certificationEmailSentAt = groupMemberResult[0].certificationEmailSentAt ?? null;
|
||||
|
||||
// Get all lessons for the course
|
||||
const lessons = await db
|
||||
.select({ id: schema.lesson.id })
|
||||
.from(schema.lesson)
|
||||
.where(eq(schema.lesson.courseId, courseId));
|
||||
const [lessons, completedLessons, exercises, [completedRow]] = await Promise.all([
|
||||
db.select({ id: schema.lesson.id }).from(schema.lesson).where(eq(schema.lesson.courseId, courseId)),
|
||||
|
||||
const lessonsCount = lessons.length;
|
||||
db
|
||||
.select({ id: schema.lessonCompletion.id })
|
||||
.from(schema.lessonCompletion)
|
||||
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lesson.courseId, courseId),
|
||||
eq(schema.lessonCompletion.profileId, profileId),
|
||||
eq(schema.lessonCompletion.isComplete, true)
|
||||
)
|
||||
),
|
||||
|
||||
// Get completed lessons (lesson_completion where is_complete = true and profile_id matches)
|
||||
const completedLessons = await db
|
||||
.select({ id: schema.lessonCompletion.id })
|
||||
.from(schema.lessonCompletion)
|
||||
.innerJoin(schema.lesson, eq(schema.lessonCompletion.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.lesson.courseId, courseId),
|
||||
eq(schema.lessonCompletion.profileId, profileId),
|
||||
eq(schema.lessonCompletion.isComplete, true)
|
||||
db
|
||||
.select({ id: schema.exercise.id })
|
||||
.from(schema.exercise)
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId))),
|
||||
|
||||
db
|
||||
.select({
|
||||
exercisesCompleted: sql<number>`count(distinct ${schema.exercise.id})::int`
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)),
|
||||
eq(schema.submission.submittedBy, groupMemberId)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const lessonsCompleted = completedLessons.length;
|
||||
|
||||
// Get all exercises for the course (legacy lesson_id support)
|
||||
const exercises = await db
|
||||
.select({ id: schema.exercise.id })
|
||||
.from(schema.exercise)
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)));
|
||||
|
||||
const exercisesCount = exercises.length;
|
||||
|
||||
// Get completed exercises (submissions where submitted_by = groupmember.id)
|
||||
const completedExercises = await db
|
||||
.select({ id: schema.submission.id })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.exercise, eq(schema.submission.exerciseId, schema.exercise.id))
|
||||
.leftJoin(schema.lesson, eq(schema.exercise.lessonId, schema.lesson.id))
|
||||
.where(
|
||||
and(
|
||||
or(eq(schema.exercise.courseId, courseId), eq(schema.lesson.courseId, courseId)),
|
||||
eq(schema.submission.submittedBy, groupMemberId)
|
||||
)
|
||||
);
|
||||
|
||||
const exercisesCompleted = completedExercises.length;
|
||||
]);
|
||||
|
||||
return {
|
||||
lessonsCount,
|
||||
lessonsCompleted,
|
||||
exercisesCount,
|
||||
exercisesCompleted
|
||||
lessonsCount: lessons.length,
|
||||
lessonsCompleted: completedLessons.length,
|
||||
exercisesCount: exercises.length,
|
||||
exercisesCompleted: completedRow?.exercisesCompleted ?? 0,
|
||||
groupMemberId,
|
||||
roleId,
|
||||
certificateEarnedAt,
|
||||
certificationEmailSentAt
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getCourseProgress error:', error);
|
||||
@@ -507,6 +516,46 @@ export async function getCourseProgress(
|
||||
}
|
||||
}
|
||||
|
||||
/** Course + org fields needed for certification email and threshold rules */
|
||||
export type TCourseCertificationRow = {
|
||||
certificate: {
|
||||
isDownloadable?: boolean;
|
||||
theme?: string;
|
||||
deadline?: string | null;
|
||||
threshold?: number;
|
||||
requiredExerciseId?: string | null;
|
||||
exerciseMinScorePercent?: number | null;
|
||||
emailMessage?: string | null;
|
||||
} | null;
|
||||
title: string;
|
||||
orgSiteName: string | null;
|
||||
orgName: string;
|
||||
};
|
||||
|
||||
export async function getCourseCertificationRow(courseId: string): Promise<TCourseCertificationRow | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({
|
||||
certificate: schema.course.certificate,
|
||||
title: schema.course.title,
|
||||
orgSiteName: schema.organization.siteName,
|
||||
orgName: schema.organization.name
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getCourseCertificationRow error:', error);
|
||||
throw new Error(
|
||||
`Failed to get course certification row: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface GetOrgCoursesOptions {
|
||||
/** Organization ID (required) */
|
||||
orgId: string;
|
||||
|
||||
@@ -307,3 +307,39 @@ export async function getProfileByGroupMemberId(groupMemberId: string): Promise<
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets when the learner first met certification threshold for this course membership.
|
||||
*/
|
||||
export async function setMemberCertificateEarned(memberId: string, earnedAt: string): Promise<TGroupmember | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.groupmember)
|
||||
.set({ certificateEarnedAt: earnedAt })
|
||||
.where(eq(schema.groupmember.id, memberId))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('setMemberCertificateEarned error:', error);
|
||||
throw new Error(`Failed to set certificate earned: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the certification congratulations email was sent.
|
||||
*/
|
||||
export async function setMemberCertificationEmailSent(memberId: string, sentAt: string): Promise<TGroupmember | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.groupmember)
|
||||
.set({ certificationEmailSentAt: sentAt })
|
||||
.where(eq(schema.groupmember.id, memberId))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('setMemberCertificationEmailSent error:', error);
|
||||
throw new Error(
|
||||
`Failed to set certification email sent: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ import type {
|
||||
TOrganization,
|
||||
TOrganizationPlan
|
||||
} from '@db/types';
|
||||
import { and, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import type { TAudienceSortBy, TAudienceSortOrder } from '@cio/utils/validation/organization';
|
||||
|
||||
export function getOrgIdBySiteName(siteName: string) {
|
||||
return db.select().from(schema.organization).where(eq(schema.organization.siteName, siteName)).limit(1);
|
||||
@@ -246,6 +247,27 @@ export const deleteOrganizationMember = async (orgId: string, memberId: number)
|
||||
return deleted || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a student organization member by ID
|
||||
* @param orgId Organization ID
|
||||
* @param memberId Member ID to delete
|
||||
* @returns Deleted member or null if not found
|
||||
*/
|
||||
export const deleteOrganizationAudienceMember = async (orgId: string, memberId: number) => {
|
||||
const [deleted] = await db
|
||||
.delete(schema.organizationmember)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
eq(schema.organizationmember.id, memberId),
|
||||
eq(schema.organizationmember.roleId, ROLE.STUDENT)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return deleted || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a user is an admin of an organization
|
||||
* @param orgId Organization ID
|
||||
@@ -349,38 +371,93 @@ export const getOrganizationTeam = async (orgId: string) => {
|
||||
* Includes invited members without a profile (LEFT JOIN profile).
|
||||
* Row id is organizationmember.id; use profileId for profile-backed actions when present.
|
||||
*/
|
||||
export const getOrganizationAudience = async (orgId: string) => {
|
||||
type GetOrganizationAudienceOptions = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sortBy?: TAudienceSortBy;
|
||||
sortOrder?: TAudienceSortOrder;
|
||||
};
|
||||
|
||||
export const getOrganizationAudience = async (orgId: string, options: GetOrganizationAudienceOptions = {}) => {
|
||||
const page = options.page && options.page > 0 ? options.page : 1;
|
||||
const limit = options.limit && options.limit > 0 ? Math.min(options.limit, 100) : 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const search = options.search?.trim();
|
||||
const sortBy = options.sortBy ?? 'createdAt';
|
||||
const sortOrder = options.sortOrder ?? 'desc';
|
||||
|
||||
const audienceNameSql = sql<string>`COALESCE(NULLIF(${schema.profile.fullname}, ''), ${schema.profile.email}, ${schema.organizationmember.email})`;
|
||||
const audienceEmailSql = sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`;
|
||||
const audienceCreatedAtSql = sql<string>`COALESCE(${schema.profile.createdAt}, ${schema.organizationmember.createdAt})`;
|
||||
|
||||
const conditions = [
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
eq(schema.organizationmember.roleId, ROLE.STUDENT)
|
||||
];
|
||||
|
||||
if (search) {
|
||||
const searchValue = `%${search}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(schema.profile.fullname, searchValue),
|
||||
ilike(schema.profile.email, searchValue),
|
||||
ilike(schema.organizationmember.email, searchValue)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
const [totalRow] = await db
|
||||
.select({ count: count(schema.organizationmember.id) })
|
||||
.from(schema.organizationmember)
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(whereClause);
|
||||
|
||||
const total = Number(totalRow?.count ?? 0);
|
||||
|
||||
const orderByExpression =
|
||||
sortBy === 'name' ? audienceNameSql : sortBy === 'email' ? audienceEmailSql : audienceCreatedAtSql;
|
||||
const orderedExpression = sortOrder === 'asc' ? asc(orderByExpression) : desc(orderByExpression);
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
memberId: schema.organizationmember.id,
|
||||
profileId: schema.profile.id,
|
||||
fullname: schema.profile.fullname,
|
||||
email: sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'),
|
||||
email: audienceEmailSql.as('email'),
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
profileCreatedAt: schema.profile.createdAt,
|
||||
memberCreatedAt: schema.organizationmember.createdAt
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
|
||||
);
|
||||
.where(whereClause)
|
||||
.orderBy(orderedExpression, desc(schema.organizationmember.id))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return result.map((row) => {
|
||||
const email = row.email?.trim() ?? '';
|
||||
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
|
||||
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
|
||||
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
|
||||
return {
|
||||
items: result.map((row) => {
|
||||
const email = row.email?.trim() ?? '';
|
||||
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
|
||||
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
|
||||
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
|
||||
|
||||
return {
|
||||
id: row.memberId,
|
||||
profileId: row.profileId ?? null,
|
||||
name,
|
||||
email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
createdAt
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: row.memberId,
|
||||
profileId: row.profileId ?? null,
|
||||
name,
|
||||
email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
createdAt
|
||||
};
|
||||
}),
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit))
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -760,7 +837,8 @@ export async function getOrgMembersByProfileIds(orgId: string, profileIds: strin
|
||||
return db
|
||||
.select({
|
||||
profileId: schema.organizationmember.profileId,
|
||||
email: schema.organizationmember.email
|
||||
email: schema.organizationmember.email,
|
||||
roleId: schema.organizationmember.roleId
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
@@ -776,3 +854,51 @@ export async function getOrgMembersByProfileIds(orgId: string, profileIds: strin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type TOrganizationMemberByEmail = {
|
||||
normalizedEmail: string;
|
||||
profileId: string | null;
|
||||
roleId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves organization members matching normalized emails (profile email or organizationmember.email).
|
||||
*/
|
||||
export async function getOrganizationMembersByNormalizedEmails(
|
||||
orgId: string,
|
||||
emails: string[]
|
||||
): Promise<TOrganizationMemberByEmail[]> {
|
||||
try {
|
||||
if (emails.length === 0) return [];
|
||||
|
||||
const normalized = [...new Set(emails.map((e) => e.toLowerCase().trim()).filter(Boolean))];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
roleId: schema.organizationmember.roleId,
|
||||
profileId: schema.organizationmember.profileId,
|
||||
matchEmail: sql<string>`lower(trim(coalesce(${schema.profile.email}, ${schema.organizationmember.email})))`.as(
|
||||
'matchEmail'
|
||||
)
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
inArray(sql`lower(trim(coalesce(${schema.profile.email}, ${schema.organizationmember.email})))`, normalized)
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
normalizedEmail: row.matchEmail,
|
||||
profileId: row.profileId,
|
||||
roleId: row.roleId
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getOrganizationMembersByNormalizedEmails error:', error);
|
||||
throw new Error(
|
||||
`Failed to get organization members by emails: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { TNewQuestionAnswer, TNewSubmission, TQuestionAnswer, TSubmission } from '@db/types';
|
||||
import { and, db, eq, inArray } from '@db/drizzle';
|
||||
import { and, count, db, eq, inArray, sql } from '@db/drizzle';
|
||||
|
||||
/**
|
||||
* Gets a submission by ID
|
||||
@@ -24,6 +24,23 @@ export async function getSubmissionById(submissionId: string): Promise<TSubmissi
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasSubmission(exerciseId: string, submittedBy: string): Promise<boolean> {
|
||||
try {
|
||||
const [result] = await db
|
||||
.select({ total: count() })
|
||||
.from(schema.submission)
|
||||
.where(and(eq(schema.submission.exerciseId, exerciseId), eq(schema.submission.submittedBy, submittedBy)))
|
||||
.limit(1);
|
||||
|
||||
return (result?.total ?? 0) > 0;
|
||||
} catch (error) {
|
||||
console.error('hasSubmission error:', error);
|
||||
throw new Error(
|
||||
`Failed to check submission existence: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets submissions by course ID with full related data (answers, profile, etc.)
|
||||
* @param courseId Course ID
|
||||
@@ -266,6 +283,21 @@ export async function upsertQuestionAnswer(data: TNewQuestionAnswer): Promise<TQ
|
||||
}
|
||||
}
|
||||
|
||||
export async function insertQuestionAnswersBatch(rows: TNewQuestionAnswer[]): Promise<TQuestionAnswer[]> {
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return db.insert(schema.questionAnswer).values(rows).returning();
|
||||
} catch (error) {
|
||||
console.error('insertQuestionAnswersBatch error:', error);
|
||||
throw new Error(
|
||||
`Failed to insert question answers batch: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a question answer
|
||||
* @param submissionId Submission ID
|
||||
@@ -311,13 +343,15 @@ export async function updateSubmissionGrades(
|
||||
): Promise<TSubmission | null> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
for (const { questionId, points } of data.answers) {
|
||||
await tx
|
||||
.update(schema.questionAnswer)
|
||||
.set({ point: points })
|
||||
.where(
|
||||
and(eq(schema.questionAnswer.submissionId, submissionId), eq(schema.questionAnswer.questionId, questionId))
|
||||
);
|
||||
if (data.answers.length > 0) {
|
||||
const valueRows = data.answers.map((a) => sql`(${a.questionId}::bigint, ${a.points}::bigint)`);
|
||||
await tx.execute(sql`
|
||||
UPDATE question_answer AS qa
|
||||
SET point = v.point
|
||||
FROM (VALUES ${sql.join(valueRows, sql`, `)}) AS v(question_id, point)
|
||||
WHERE qa.submission_id = ${submissionId}
|
||||
AND qa.question_id = v.question_id
|
||||
`);
|
||||
}
|
||||
|
||||
const submissionData: Partial<TSubmission> = {
|
||||
|
||||
@@ -587,13 +587,15 @@ export const course = pgTable(
|
||||
currency: varchar().default('USD').notNull(),
|
||||
bannerImage: text('banner_image'),
|
||||
isPublished: boolean('is_published').default(false),
|
||||
isCertificateDownloadable: boolean('is_certificate_downloadable').default(false),
|
||||
certificateTheme: text('certificate_theme'),
|
||||
certificationDeadline: timestamp('certification_deadline', { withTimezone: true, mode: 'string' }),
|
||||
certificationThreshold: integer('certification_threshold').default(100),
|
||||
certificationRequiredExerciseId: uuid('certification_required_exercise_id'),
|
||||
certificationExerciseMinScorePercent: integer('certification_exercise_min_score_percent'),
|
||||
certificationEmailMessage: text('certification_email_message'),
|
||||
certificate: jsonb().default({}).$type<{
|
||||
isDownloadable?: boolean;
|
||||
theme?: string;
|
||||
deadline?: string | null;
|
||||
threshold?: number;
|
||||
requiredExerciseId?: string | null;
|
||||
exerciseMinScorePercent?: number | null;
|
||||
emailMessage?: string | null;
|
||||
}>(),
|
||||
status: text().default('ACTIVE').notNull(),
|
||||
type: courseType().default('LIVE_CLASS')
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { seedGroupmembers } from '@db/utils/seed/groupmember';
|
||||
import { type LessonTemplate, seedLessons } from '@db/utils/seed/lesson';
|
||||
import { seedOrganization } from '@db/utils/seed/organization';
|
||||
import { seedOrganizationMember } from '@db/utils/seed/organizationmember';
|
||||
import { seedEnterpriseOrganizationPlan } from '@db/utils/seed/organizationPlan';
|
||||
import { seedProfile } from '@db/utils/seed/profile';
|
||||
import { seedQuestionTypes } from '@db/utils/seed/questionType';
|
||||
import { seedQuestions } from '@db/utils/seed/question';
|
||||
@@ -28,6 +29,9 @@ const MVC_COURSE_ID = '98e6e798-f0bd-4f9d-a6f5-ce0816a4f97e';
|
||||
const REACT_COURSE_ID = '16e3bc8d-5d1b-4708-988e-93abae288ccf';
|
||||
const PANDAS_COURSE_ID = 'f0a85d18-aff4-412f-b8e6-3b34ef098dce';
|
||||
const STUDENT_USER_ID = '0c256e75-aa40-4f62-8d30-0217ca1c60d9';
|
||||
const ENTERPRISE_ORG_ID = '2b8f4a1c-6d3e-4b2a-9f7e-1c4d8a6e9b0f';
|
||||
const ENTERPRISE_ADMIN_USER_ID = '3c9052de-7e4f-4c3a-8d8e-2d5e9b7f1a3c';
|
||||
const ENTERPRISE_STUDENT_USER_ID = '4da163ef-8050-4d4b-9c9f-3e6fac802b4d';
|
||||
|
||||
// Parse command line arguments
|
||||
function parseFlags(): Set<string> {
|
||||
@@ -62,6 +66,7 @@ Flags:
|
||||
--accounts Seed accounts
|
||||
--organizations Seed organizations
|
||||
--organization-members Seed organization members
|
||||
--organization-plan Seed enterprise plan for Coursera Test org
|
||||
--groups Seed groups
|
||||
--group-members Seed group members
|
||||
--courses Seed courses
|
||||
@@ -108,11 +113,22 @@ const seedFunctions = {
|
||||
},
|
||||
organizations: async () => {
|
||||
console.log('📝 Seeding organizations...');
|
||||
await seedOrganization({ testOrgId: TEST_ORG_ID });
|
||||
await seedOrganization({ testOrgId: TEST_ORG_ID, enterpriseOrgId: ENTERPRISE_ORG_ID });
|
||||
},
|
||||
'organization-members': async () => {
|
||||
console.log('📝 Seeding organization members...');
|
||||
await seedOrganizationMember({ testOrgId: TEST_ORG_ID, adminUserId: ADMIN_USER_ID });
|
||||
await seedOrganizationMember({
|
||||
testOrgId: TEST_ORG_ID,
|
||||
adminUserId: ADMIN_USER_ID,
|
||||
studentUserId: STUDENT_USER_ID,
|
||||
enterpriseOrgId: ENTERPRISE_ORG_ID,
|
||||
enterpriseAdminUserId: ENTERPRISE_ADMIN_USER_ID,
|
||||
enterpriseStudentUserId: ENTERPRISE_STUDENT_USER_ID
|
||||
});
|
||||
},
|
||||
'organization-plan': async () => {
|
||||
console.log('📝 Seeding enterprise organization plan...');
|
||||
await seedEnterpriseOrganizationPlan({ enterpriseOrgId: ENTERPRISE_ORG_ID });
|
||||
},
|
||||
groups: async () => {
|
||||
console.log('📝 Seeding groups...');
|
||||
@@ -205,6 +221,7 @@ async function seed() {
|
||||
await seedFunctions.accounts();
|
||||
await seedFunctions.organizations();
|
||||
await seedFunctions['organization-members']();
|
||||
await seedFunctions['organization-plan']();
|
||||
await seedFunctions.groups();
|
||||
await seedFunctions['group-members']();
|
||||
await seedFunctions.courses();
|
||||
@@ -225,6 +242,7 @@ async function seed() {
|
||||
'accounts',
|
||||
'organizations',
|
||||
'organization-members',
|
||||
'organization-plan',
|
||||
'groups',
|
||||
'group-members',
|
||||
'courses',
|
||||
|
||||
@@ -39,7 +39,10 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
|
||||
cost: 0,
|
||||
currency: 'NGN',
|
||||
isPublished: true,
|
||||
isCertificateDownloadable: false,
|
||||
certificate: {
|
||||
isDownloadable: false,
|
||||
theme: 'professional'
|
||||
},
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
@@ -63,7 +66,10 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
|
||||
cost: 0,
|
||||
currency: 'NGN',
|
||||
isPublished: true,
|
||||
isCertificateDownloadable: false,
|
||||
certificate: {
|
||||
isDownloadable: false,
|
||||
theme: 'professional'
|
||||
},
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
@@ -87,7 +93,10 @@ export async function seedCourses({ mvcGroupId, reactGroupId, pandasGroupId }: S
|
||||
cost: 0,
|
||||
currency: 'NGN',
|
||||
isPublished: true,
|
||||
isCertificateDownloadable: false,
|
||||
certificate: {
|
||||
isDownloadable: false,
|
||||
theme: 'professional'
|
||||
},
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
].filter((c) => !existingCourseIds.includes(c.id));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db, organization } from '@db/drizzle';
|
||||
|
||||
export async function seedOrganization({ testOrgId }: { testOrgId: string }) {
|
||||
export async function seedOrganization({ testOrgId, enterpriseOrgId }: { testOrgId: string; enterpriseOrgId: string }) {
|
||||
const existingOrgs = await db.select().from(organization);
|
||||
const existingOrgIds = existingOrgs.map((o) => o.id);
|
||||
|
||||
@@ -17,6 +17,19 @@ export async function seedOrganization({ testOrgId }: { testOrgId: string }) {
|
||||
course: { grading: true, newsfeed: true },
|
||||
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: enterpriseOrgId,
|
||||
name: 'Coursera Test',
|
||||
siteName: 'coursera-test',
|
||||
settings: {},
|
||||
landingpage: {},
|
||||
theme: '',
|
||||
customization: {
|
||||
apps: { poll: true, comments: true },
|
||||
course: { grading: true, newsfeed: true },
|
||||
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
|
||||
}
|
||||
}
|
||||
].filter((o) => !existingOrgIds.includes(o.id));
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { and, db, eq, organizationPlan } from '@db/drizzle';
|
||||
|
||||
export async function seedEnterpriseOrganizationPlan({ enterpriseOrgId }: { enterpriseOrgId: string }) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(organizationPlan)
|
||||
.where(
|
||||
and(
|
||||
eq(organizationPlan.orgId, enterpriseOrgId),
|
||||
eq(organizationPlan.planName, 'ENTERPRISE'),
|
||||
eq(organizationPlan.isActive, true)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
console.log(' ✓ Enterprise organization plan already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(organizationPlan).values({
|
||||
orgId: enterpriseOrgId,
|
||||
planName: 'ENTERPRISE',
|
||||
isActive: true,
|
||||
subscriptionId: 'seed-coursera-test-enterprise',
|
||||
provider: 'seed'
|
||||
});
|
||||
console.log(' ✓ Inserted enterprise organization plan');
|
||||
}
|
||||
@@ -3,14 +3,23 @@ import { db, organizationmember } from '@db/drizzle';
|
||||
interface SeedOrganizationMember {
|
||||
testOrgId: string;
|
||||
adminUserId: string;
|
||||
studentUserId: string;
|
||||
enterpriseOrgId: string;
|
||||
enterpriseAdminUserId: string;
|
||||
enterpriseStudentUserId: string;
|
||||
}
|
||||
|
||||
export async function seedOrganizationMember({ testOrgId, adminUserId }: SeedOrganizationMember) {
|
||||
export async function seedOrganizationMember({
|
||||
testOrgId,
|
||||
adminUserId,
|
||||
studentUserId,
|
||||
enterpriseOrgId,
|
||||
enterpriseAdminUserId,
|
||||
enterpriseStudentUserId
|
||||
}: SeedOrganizationMember) {
|
||||
const existingOrgMembers = await db.select().from(organizationmember);
|
||||
const existingOrgMemberKeys = existingOrgMembers.map((om) => `${om.organizationId}-${om.profileId}`);
|
||||
|
||||
const studentUserId = '0c256e75-aa40-4f62-8d30-0217ca1c60d9';
|
||||
|
||||
const orgMembersToInsert = [
|
||||
{
|
||||
organizationId: testOrgId,
|
||||
@@ -23,6 +32,18 @@ export async function seedOrganizationMember({ testOrgId, adminUserId }: SeedOrg
|
||||
roleId: 3, // STUDENT
|
||||
profileId: studentUserId,
|
||||
verified: false
|
||||
},
|
||||
{
|
||||
organizationId: enterpriseOrgId,
|
||||
roleId: 1, // ADMIN
|
||||
profileId: enterpriseAdminUserId,
|
||||
verified: false
|
||||
},
|
||||
{
|
||||
organizationId: enterpriseOrgId,
|
||||
roleId: 3, // STUDENT
|
||||
profileId: enterpriseStudentUserId,
|
||||
verified: false
|
||||
}
|
||||
].filter((om) => !existingOrgMemberKeys.includes(`${om.organizationId}-${om.profileId}`));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user