feat(cohorts): rename Programs to Cohorts with data migration (#694)

* feat(cohorts): rename Programs to Cohorts with data migration

- Add cohort database tables and Drizzle migration alongside legacy program tables
- Introduce migrate-programs-to-cohorts script to copy existing program data safely
- Rename API routes (/cohort), services, queries, validation, and error codes
- Rename dashboard feature module, routes (/cohorts), navigation, and translations
- Update docs, emails, and internal compliance cron endpoints for cohort terminology
- Keep legacy program tables in place for rollback until explicitly removed

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): restore migrate:answer-data script alongside cohort migration

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): cast program enums when migrating data to cohort tables

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): truncate cohort tables before program-to-cohort copy

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): report accurate row counts in programs-to-cohorts migration

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-02 12:10:31 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent fe9f8c441f
commit ec61bfe7d9
115 changed files with 19703 additions and 6046 deletions
+761
View File
@@ -0,0 +1,761 @@
import * as schema from '@db/schema';
import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db, type DbOrTxClient } from '@db/drizzle';
// ─── Types ───────────────────────────────────────────────────────────────────
export type TCohort = typeof schema.cohort.$inferSelect;
export type TNewCohort = typeof schema.cohort.$inferInsert;
export type TCohortMember = typeof schema.cohortMember.$inferSelect;
export type TNewCohortMember = typeof schema.cohortMember.$inferInsert;
export type TCohortCourse = typeof schema.cohortCourse.$inferSelect;
export type TCohortNewsfeed = typeof schema.cohortNewsfeed.$inferSelect;
export type TNewCohortNewsfeed = typeof schema.cohortNewsfeed.$inferInsert;
export type TCohortNewsfeedComment = typeof schema.cohortNewsfeedComment.$inferSelect;
export type TNewCohortNewsfeedComment = typeof schema.cohortNewsfeedComment.$inferInsert;
// ─── Program CRUD ────────────────────────────────────────────────────────────
export async function createCohort(data: TNewCohort): Promise<TCohort> {
try {
const [cohort] = await db.insert(schema.cohort).values(data).returning();
if (!cohort) throw new Error('Failed to create cohort');
return cohort;
} catch (error) {
console.error('createCohort error:', error);
throw new Error(`Failed to create cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** Inserts the cohort and adds `creatorProfileId` as a cohort team member (tutor) in one transaction. */
export async function createCohortWithCreatorMembership(
cohortData: TNewCohort,
creatorProfileId: string
): Promise<TCohort> {
try {
return await db.transaction(async (tx) => {
const [cohort] = await tx.insert(schema.cohort).values(cohortData).returning();
if (!cohort) {
throw new Error('Failed to create cohort');
}
await tx.insert(schema.cohortMember).values({
cohortId: cohort.id,
profileId: creatorProfileId,
roleId: ROLE.TUTOR
});
return cohort;
});
} catch (error) {
console.error('createCohortWithCreatorMembership error:', error);
throw new Error(`Failed to create cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCohortById(cohortId: string): Promise<TCohort | null> {
try {
const [cohort] = await db.select().from(schema.cohort).where(eq(schema.cohort.id, cohortId)).limit(1);
return cohort || null;
} catch (error) {
console.error('getCohortById error:', error);
throw new Error(`Failed to get cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCohortsByOrg(
organizationId: string,
cohortIds?: string[]
): Promise<Array<TCohort & { courseCount: number; studentCount: number }>> {
try {
const whereCondition =
cohortIds && cohortIds.length > 0
? and(eq(schema.cohort.organizationId, organizationId), inArray(schema.cohort.id, cohortIds))
: eq(schema.cohort.organizationId, organizationId);
const result = await db
.select({
cohort: schema.cohort,
courseCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.cohortCourse}
WHERE ${eq(schema.cohortCourse.cohortId, schema.cohort.id)}),
0
)
`.as('courseCount'),
studentCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.cohortMember}
WHERE ${and(
eq(schema.cohortMember.cohortId, schema.cohort.id),
eq(schema.cohortMember.roleId, ROLE.STUDENT)
)}),
0
)
`.as('studentCount')
})
.from(schema.cohort)
.where(whereCondition)
.orderBy(desc(schema.cohort.createdAt));
return result.map((row) => ({
...row.cohort,
courseCount: Number(row.courseCount || 0),
studentCount: Number(row.studentCount || 0)
}));
} catch (error) {
console.error('getCohortsByOrg error:', error);
throw new Error(
`Failed to get cohorts for org "${organizationId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export interface TSearchOrgCohort {
id: string;
name: string;
description: string | null;
status: string;
updatedAt: string | null;
}
export async function searchOrgCohorts(orgId: string, search: string, limit: number): Promise<TSearchOrgCohort[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.cohort.id,
name: schema.cohort.name,
description: schema.cohort.description,
status: schema.cohort.status,
updatedAt: schema.cohort.updatedAt
})
.from(schema.cohort)
.where(
and(
eq(schema.cohort.organizationId, orgId),
eq(schema.cohort.status, 'ACTIVE'),
or(ilike(schema.cohort.name, searchValue), ilike(schema.cohort.description, searchValue))
)
)
.orderBy(desc(schema.cohort.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchOrgCohorts error:', error);
throw new Error(`Failed to search org cohorts: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function searchLmsCohorts(
orgId: string,
profileId: string,
search: string,
limit: number
): Promise<TSearchOrgCohort[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.cohort.id,
name: schema.cohort.name,
description: schema.cohort.description,
status: schema.cohort.status,
updatedAt: schema.cohort.updatedAt
})
.from(schema.cohortMember)
.innerJoin(schema.cohort, eq(schema.cohortMember.cohortId, schema.cohort.id))
.where(
and(
eq(schema.cohortMember.profileId, profileId),
eq(schema.cohort.organizationId, orgId),
eq(schema.cohort.status, 'ACTIVE'),
or(ilike(schema.cohort.name, searchValue), ilike(schema.cohort.description, searchValue))
)
)
.orderBy(desc(schema.cohort.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchLmsCohorts error:', error);
throw new Error(`Failed to search LMS cohorts: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExistingCohortMembers(
pairs: Array<{ cohortId: string; profileId: string }>
): Promise<Set<string>> {
if (pairs.length === 0) {
return new Set();
}
try {
const cohortIds = [...new Set(pairs.map((pair) => pair.cohortId))];
const profileIds = [...new Set(pairs.map((pair) => pair.profileId))];
const rows = await db
.select({ cohortId: schema.cohortMember.cohortId, profileId: schema.cohortMember.profileId })
.from(schema.cohortMember)
.where(and(inArray(schema.cohortMember.cohortId, cohortIds), inArray(schema.cohortMember.profileId, profileIds)));
return new Set(
rows
.filter((row): row is { cohortId: string; profileId: string } => Boolean(row.cohortId && row.profileId))
.map((row) => `${row.cohortId}:${row.profileId}`)
);
} catch (error) {
console.error('getExistingCohortMembers error:', error);
throw new Error(
`Failed to check existing cohort members: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCohort(cohortId: string, data: Partial<TNewCohort>): Promise<TCohort | null> {
try {
const [updated] = await db
.update(schema.cohort)
.set({ ...data, updatedAt: sql`now()` })
.where(eq(schema.cohort.id, cohortId))
.returning();
return updated || null;
} catch (error) {
console.error('updateCohort error:', error);
throw new Error(
`Failed to update cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteCohort(cohortId: string): Promise<TCohort | null> {
try {
const [deleted] = await db.delete(schema.cohort).where(eq(schema.cohort.id, cohortId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteCohort error:', error);
throw new Error(
`Failed to delete cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ─── Program Membership ──────────────────────────────────────────────────────
export async function getCohortMemberByProfileId(cohortId: string, profileId: string): Promise<TCohortMember | null> {
try {
const [member] = await db
.select()
.from(schema.cohortMember)
.where(and(eq(schema.cohortMember.cohortId, cohortId), eq(schema.cohortMember.profileId, profileId)))
.limit(1);
return member || null;
} catch (error) {
console.error('getCohortMemberByProfileId error:', error);
throw new Error(`Failed to get cohort member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function isCohortMember(cohortId: string, profileId: string): Promise<boolean> {
try {
const member = await getCohortMemberByProfileId(cohortId, profileId);
return member !== null;
} catch (error) {
console.error('isCohortMember error:', error);
throw new Error(`Failed to check cohort membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCohortMemberRole(cohortId: string, profileId: string): Promise<number | null> {
try {
const member = await getCohortMemberByProfileId(cohortId, profileId);
return member?.roleId || null;
} catch (error) {
console.error('getCohortMemberRole error:', error);
throw new Error(`Failed to get cohort member role: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function isOrgAdminByCohortId(cohortId: string, profileId: string): Promise<boolean> {
try {
const result = await db
.select({ orgMemberId: schema.organizationmember.id })
.from(schema.cohort)
.innerJoin(
schema.organizationmember,
and(
eq(schema.organizationmember.organizationId, schema.cohort.organizationId),
eq(schema.organizationmember.profileId, profileId),
eq(schema.organizationmember.roleId, ROLE.ADMIN)
)
)
.where(eq(schema.cohort.id, cohortId))
.limit(1);
return result.length > 0;
} catch (error) {
console.error('isOrgAdminByCohortId error:', error);
throw new Error(`Failed to check org admin status: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function addCohortMember(data: TNewCohortMember, dbClient: DbOrTxClient = db): Promise<TCohortMember> {
try {
const [member] = await dbClient.insert(schema.cohortMember).values(data).returning();
if (!member) throw new Error('Failed to add cohort member');
return member;
} catch (error) {
console.error('addCohortMember error:', error);
throw new Error(`Failed to add cohort member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function removeCohortMember(memberId: string): Promise<TCohortMember | null> {
try {
const [deleted] = await db.delete(schema.cohortMember).where(eq(schema.cohortMember.id, memberId)).returning();
return deleted || null;
} catch (error) {
console.error('removeCohortMember error:', error);
throw new Error(
`Failed to remove cohort member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCohortMember(
memberId: string,
data: Partial<TNewCohortMember>
): Promise<TCohortMember | null> {
try {
const [updated] = await db
.update(schema.cohortMember)
.set(data)
.where(eq(schema.cohortMember.id, memberId))
.returning();
return updated || null;
} catch (error) {
console.error('updateCohortMember error:', error);
throw new Error(
`Failed to update cohort member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCohortMembers(cohortId: string): Promise<
Array<
TCohortMember & {
profile: {
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
} | null;
}
>
> {
try {
const result = await db
.select({
member: schema.cohortMember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.cohortMember)
.leftJoin(schema.profile, eq(schema.cohortMember.profileId, schema.profile.id))
.where(eq(schema.cohortMember.cohortId, cohortId))
.orderBy(asc(schema.cohortMember.createdAt));
return result.map((row) => ({
...row.member,
profile: row.profile?.id ? row.profile : null
}));
} catch (error) {
console.error('getCohortMembers error:', error);
throw new Error(
`Failed to get cohort members for "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getEnrolledCohortsByProfile(
profileId: string
): Promise<Array<TCohort & { roleId: number; courseCount: number; studentCount: number }>> {
try {
const result = await db
.select({
cohort: schema.cohort,
roleId: schema.cohortMember.roleId,
courseCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.cohortCourse}
INNER JOIN ${schema.course}
ON ${schema.cohortCourse.courseId} = ${schema.course.id}
WHERE ${schema.cohortCourse.cohortId} = ${schema.cohort.id}
AND (
${schema.cohortMember.roleId} <> ${ROLE.STUDENT}
OR ${schema.course.isPublished} = true
)),
0
)
`.as('courseCount'),
studentCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.cohortMember} AS student_member
WHERE student_member.cohort_id = ${schema.cohort.id}
AND student_member.role_id = ${ROLE.STUDENT}),
0
)
`.as('studentCount')
})
.from(schema.cohortMember)
.innerJoin(schema.cohort, eq(schema.cohortMember.cohortId, schema.cohort.id))
.where(eq(schema.cohortMember.profileId, profileId))
.orderBy(desc(schema.cohort.createdAt));
return result.map((row) => ({
...row.cohort,
roleId: row.roleId,
courseCount: Number(row.courseCount || 0),
studentCount: Number(row.studentCount || 0)
}));
} catch (error) {
console.error('getEnrolledCohortsByProfile error:', error);
throw new Error(
`Failed to get enrolled cohorts for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ─── Program Courses ─────────────────────────────────────────────────────────
export async function addCourseToCohort(cohortId: string, courseId: string): Promise<TCohortCourse> {
try {
const [row] = await db.insert(schema.cohortCourse).values({ cohortId, courseId }).returning();
if (!row) throw new Error('Failed to add course to cohort');
return row;
} catch (error) {
console.error('addCourseToCohort error:', error);
throw new Error(`Failed to add course to cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function removeCourseFromCohort(cohortId: string, courseId: string): Promise<TCohortCourse | null> {
try {
const [deleted] = await db
.delete(schema.cohortCourse)
.where(and(eq(schema.cohortCourse.cohortId, cohortId), eq(schema.cohortCourse.courseId, courseId)))
.returning();
return deleted || null;
} catch (error) {
console.error('removeCourseFromCohort error:', error);
throw new Error(`Failed to remove course from cohort: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCoursesByCohort(
cohortId: string,
onlyPublished = false
): Promise<
Array<
TCohortCourse & {
course: {
id: string;
title: string | null;
description: string | null;
coverImage: string | null;
slug: string | null;
status: string | null;
};
}
>
> {
try {
const whereCondition = onlyPublished
? and(eq(schema.cohortCourse.cohortId, cohortId), eq(schema.course.isPublished, true))
: eq(schema.cohortCourse.cohortId, cohortId);
const result = await db
.select({
cohortCourse: schema.cohortCourse,
course: {
id: schema.course.id,
title: schema.course.title,
description: schema.course.description,
coverImage: schema.course.logo,
slug: schema.course.slug,
status: schema.course.status,
isPublished: schema.course.isPublished
}
})
.from(schema.cohortCourse)
.innerJoin(schema.course, eq(schema.cohortCourse.courseId, schema.course.id))
.where(whereCondition)
.orderBy(asc(schema.cohortCourse.addedAt));
return result.map((row) => ({ ...row.cohortCourse, course: row.course }));
} catch (error) {
console.error('getCoursesByCohort error:', error);
throw new Error(
`Failed to get courses for cohort "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function isCohortCourse(cohortId: string, courseId: string): Promise<boolean> {
try {
const [row] = await db
.select({ id: schema.cohortCourse.id })
.from(schema.cohortCourse)
.where(and(eq(schema.cohortCourse.cohortId, cohortId), eq(schema.cohortCourse.courseId, courseId)))
.limit(1);
return !!row;
} catch (error) {
console.error('isCohortCourse error:', error);
throw new Error(`Failed to check cohort course: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// ─── Program Newsfeed ────────────────────────────────────────────────────────
export async function getCohortNewsfeed(
cohortId: string,
options: { cursor?: string; limit: number }
): Promise<{
items: Array<
TCohortNewsfeed & {
authorProfileId: string | null;
authorFullname: string | null;
authorUsername: string | null;
authorAvatarUrl: string | null;
commentCount: number;
}
>;
totalCount: number;
hasMore: boolean;
nextCursor: string | null;
}> {
try {
const { cursor, limit } = options;
const whereConditions = [eq(schema.cohortNewsfeed.cohortId, cohortId)];
if (cursor) {
whereConditions.push(sql`${schema.cohortNewsfeed.createdAt} < ${cursor}`);
}
const totalCountResult = await db
.select({ count: sql<number>`count(*)`.as('count') })
.from(schema.cohortNewsfeed)
.where(eq(schema.cohortNewsfeed.cohortId, cohortId));
const totalCount = Number(totalCountResult[0]?.count || 0);
const feeds = await db
.select({
feed: schema.cohortNewsfeed,
profile: schema.profile,
commentCount: sql<number>`
COALESCE(
(SELECT COUNT(*)::int
FROM ${schema.cohortNewsfeedComment}
WHERE ${eq(schema.cohortNewsfeedComment.cohortNewsfeedId, schema.cohortNewsfeed.id)}),
0
)
`.as('commentCount')
})
.from(schema.cohortNewsfeed)
.leftJoin(schema.cohortMember, eq(schema.cohortNewsfeed.authorId, schema.cohortMember.id))
.leftJoin(schema.profile, eq(schema.cohortMember.profileId, schema.profile.id))
.where(and(...whereConditions))
.orderBy(desc(schema.cohortNewsfeed.createdAt))
.limit(limit + 1);
const hasMore = feeds.length > limit;
const items = feeds.slice(0, limit);
const nextCursor = hasMore && items.length > 0 ? items[items.length - 1].feed.createdAt : null;
return {
items: items.map((row) => ({
...row.feed,
authorProfileId: row.profile?.id || null,
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null,
commentCount: Number(row.commentCount || 0)
})),
totalCount,
hasMore,
nextCursor
};
} catch (error) {
console.error('getCohortNewsfeed error:', error);
throw new Error(
`Failed to get cohort newsfeed "${cohortId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCohortNewsfeedById(feedId: string): Promise<TCohortNewsfeed | null> {
try {
const [feed] = await db.select().from(schema.cohortNewsfeed).where(eq(schema.cohortNewsfeed.id, feedId)).limit(1);
return feed || null;
} catch (error) {
console.error('getCohortNewsfeedById error:', error);
throw new Error(
`Failed to get cohort newsfeed item "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCohortNewsfeed(data: TNewCohortNewsfeed): Promise<TCohortNewsfeed> {
try {
const [feed] = await db.insert(schema.cohortNewsfeed).values(data).returning();
if (!feed) throw new Error('Failed to create cohort newsfeed');
return feed;
} catch (error) {
console.error('createCohortNewsfeed error:', error);
throw new Error(`Failed to create cohort newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateCohortNewsfeed(
feedId: string,
data: Partial<TNewCohortNewsfeed>
): Promise<TCohortNewsfeed | null> {
try {
const [updated] = await db
.update(schema.cohortNewsfeed)
.set(data)
.where(eq(schema.cohortNewsfeed.id, feedId))
.returning();
return updated || null;
} catch (error) {
console.error('updateCohortNewsfeed error:', error);
throw new Error(
`Failed to update cohort newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCohortNewsfeedReaction(
feedId: string,
reaction: { clap: string[]; smile: string[]; thumbsup: string[]; thumbsdown: string[] }
): Promise<TCohortNewsfeed | null> {
try {
const [updated] = await db
.update(schema.cohortNewsfeed)
.set({ reaction })
.where(eq(schema.cohortNewsfeed.id, feedId))
.returning();
return updated || null;
} catch (error) {
console.error('updateCohortNewsfeedReaction error:', error);
throw new Error(
`Failed to update cohort newsfeed reaction "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteCohortNewsfeed(feedId: string): Promise<TCohortNewsfeed | null> {
try {
const [deleted] = await db.delete(schema.cohortNewsfeed).where(eq(schema.cohortNewsfeed.id, feedId)).returning();
return deleted || null;
} catch (error) {
console.error('deleteCohortNewsfeed error:', error);
throw new Error(
`Failed to delete cohort newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
export async function getCohortNewsfeedComments(feedId: string): Promise<
Array<
TCohortNewsfeedComment & {
authorProfileId: string | null;
authorFullname: string | null;
authorUsername: string | null;
authorAvatarUrl: string | null;
}
>
> {
try {
const result = await db
.select({
comment: schema.cohortNewsfeedComment,
profile: schema.profile
})
.from(schema.cohortNewsfeedComment)
.leftJoin(schema.cohortMember, eq(schema.cohortNewsfeedComment.authorId, schema.cohortMember.id))
.leftJoin(schema.profile, eq(schema.cohortMember.profileId, schema.profile.id))
.where(eq(schema.cohortNewsfeedComment.cohortNewsfeedId, feedId))
.orderBy(asc(schema.cohortNewsfeedComment.createdAt));
return result.map((row) => ({
...row.comment,
authorProfileId: row.profile?.id || null,
authorFullname: row.profile?.fullname || null,
authorUsername: row.profile?.username || null,
authorAvatarUrl: row.profile?.avatarUrl || null
}));
} catch (error) {
console.error('getCohortNewsfeedComments error:', error);
throw new Error(
`Failed to get cohort newsfeed comments for "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCohortNewsfeedComment(data: TNewCohortNewsfeedComment): Promise<TCohortNewsfeedComment> {
try {
const [comment] = await db.insert(schema.cohortNewsfeedComment).values(data).returning();
if (!comment) throw new Error('Failed to create cohort newsfeed comment');
return comment;
} catch (error) {
console.error('createCohortNewsfeedComment error:', error);
throw new Error(
`Failed to create cohort newsfeed comment: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteCohortNewsfeedComment(commentId: number): Promise<TCohortNewsfeedComment | null> {
try {
const [deleted] = await db
.delete(schema.cohortNewsfeedComment)
.where(eq(schema.cohortNewsfeedComment.id, commentId))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteCohortNewsfeedComment error:', error);
throw new Error(
`Failed to delete cohort newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCohortNewsfeedCommentById(commentId: number): Promise<TCohortNewsfeedComment | null> {
try {
const [comment] = await db
.select()
.from(schema.cohortNewsfeedComment)
.where(eq(schema.cohortNewsfeedComment.id, commentId))
.limit(1);
return comment || null;
} catch (error) {
console.error('getCohortNewsfeedCommentById error:', error);
throw new Error(
`Failed to get cohort newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+455
View File
@@ -0,0 +1,455 @@
import * as schema from '@db/schema';
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
// ─── Types ───────────────────────────────────────────────────────────────────
export type TCohortGoal = typeof schema.cohortGoal.$inferSelect;
export type TNewCohortGoal = typeof schema.cohortGoal.$inferInsert;
export type TCohortGoalAssignment = typeof schema.cohortGoalAssignment.$inferSelect;
export type TNewCohortGoalAssignment = typeof schema.cohortGoalAssignment.$inferInsert;
// ─── Goal CRUD ───────────────────────────────────────────────────────────────
export async function createCohortGoal(data: TNewCohortGoal): Promise<TCohortGoal> {
try {
const [goal] = await db.insert(schema.cohortGoal).values(data).returning();
if (!goal) throw new Error('Failed to create cohort goal');
return goal;
} catch (error) {
console.error('createCohortGoal error:', error);
throw new Error(`Failed to create cohort goal: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCohortGoalById(goalId: string): Promise<TCohortGoal | null> {
try {
const [goal] = await db.select().from(schema.cohortGoal).where(eq(schema.cohortGoal.id, goalId)).limit(1);
return goal ?? null;
} catch (error) {
console.error('getCohortGoalById error:', error);
throw new Error(
`Failed to get cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCohortGoals(
cohortId: string,
options: { includeArchived?: boolean } = {}
): Promise<TCohortGoal[]> {
try {
const conditions = [eq(schema.cohortGoal.cohortId, cohortId)];
if (!options.includeArchived) {
conditions.push(eq(schema.cohortGoal.status, 'active'));
}
return await db
.select()
.from(schema.cohortGoal)
.where(and(...conditions))
.orderBy(asc(schema.cohortGoal.createdAt));
} catch (error) {
console.error('getCohortGoals error:', error);
throw new Error(`Failed to list cohort goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCohortGoalsByOrg(
organizationId: string
): Promise<Array<TCohortGoal & { cohortId: string; cohortName: string }>> {
try {
const result = await db
.select({
goal: schema.cohortGoal,
cohortName: schema.cohort.name
})
.from(schema.cohortGoal)
.innerJoin(schema.cohort, eq(schema.cohortGoal.cohortId, schema.cohort.id))
.where(and(eq(schema.cohort.organizationId, organizationId), eq(schema.cohortGoal.status, 'active')));
return result.map((row) => ({ ...row.goal, cohortName: row.cohortName }));
} catch (error) {
console.error('getCohortGoalsByOrg error:', error);
throw new Error(`Failed to list goals for org: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** Every active goal across every org. Used by the daily cron sweep. */
export async function listAllActiveCohortGoals(): Promise<TCohortGoal[]> {
try {
return await db.select().from(schema.cohortGoal).where(eq(schema.cohortGoal.status, 'active'));
} catch (error) {
console.error('listAllActiveCohortGoals error:', error);
throw new Error(`Failed to list active goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* For the reminder scan: every assignment whose `dueDate` is in the future and
* whose status is not yet completed/waived. The caller decides the cadence
* (compares daysUntilDue against goal.reminderDaysBefore).
*/
export async function listAssignmentsForReminderScan(): Promise<
Array<{
assignment: TCohortGoalAssignment;
goal: TCohortGoal;
profileId: string;
email: string | null;
cohortId: string;
cohortName: string;
organizationName: string;
organizationSiteName: string | null;
organizationCustomDomain: string | null;
organizationIsCustomDomainVerified: boolean | null;
organizationAvatarUrl: string | null;
organizationTheme: string | null;
}>
> {
try {
const result = await db
.select({
assignment: schema.cohortGoalAssignment,
goal: schema.cohortGoal,
profileId: schema.cohortMember.profileId,
email: schema.cohortMember.email,
cohortId: schema.cohort.id,
cohortName: schema.cohort.name,
organizationName: schema.organization.name,
organizationSiteName: schema.organization.siteName,
organizationCustomDomain: schema.organization.customDomain,
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
organizationAvatarUrl: schema.organization.avatarUrl,
organizationTheme: schema.organization.theme
})
.from(schema.cohortGoalAssignment)
.innerJoin(schema.cohortMember, eq(schema.cohortGoalAssignment.cohortMemberId, schema.cohortMember.id))
.innerJoin(schema.cohortGoal, eq(schema.cohortGoalAssignment.goalId, schema.cohortGoal.id))
.innerJoin(schema.cohort, eq(schema.cohortGoal.cohortId, schema.cohort.id))
.innerJoin(schema.organization, eq(schema.cohort.organizationId, schema.organization.id))
.where(
and(
eq(schema.cohortGoal.status, 'active'),
inArray(schema.cohortGoalAssignment.status, ['not_started', 'in_progress', 'at_risk', 'overdue'])
)
);
return result
.filter((row) => row.profileId != null)
.map((row) => ({
assignment: row.assignment,
goal: row.goal,
profileId: row.profileId!,
email: row.email,
cohortId: row.cohortId,
cohortName: row.cohortName,
organizationName: row.organizationName,
organizationSiteName: row.organizationSiteName,
organizationCustomDomain: row.organizationCustomDomain,
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified,
organizationAvatarUrl: row.organizationAvatarUrl,
organizationTheme: row.organizationTheme
}));
} catch (error) {
console.error('listAssignmentsForReminderScan error:', error);
throw new Error(
`Failed to list assignments for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCohortGoal(goalId: string, data: Partial<TNewCohortGoal>): Promise<TCohortGoal | null> {
try {
const [updated] = await db
.update(schema.cohortGoal)
.set({ ...data, updatedAt: sql`now()` })
.where(eq(schema.cohortGoal.id, goalId))
.returning();
return updated ?? null;
} catch (error) {
console.error('updateCohortGoal error:', error);
throw new Error(
`Failed to update cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function deleteCohortGoal(goalId: string): Promise<TCohortGoal | null> {
try {
const [deleted] = await db.delete(schema.cohortGoal).where(eq(schema.cohortGoal.id, goalId)).returning();
return deleted ?? null;
} catch (error) {
console.error('deleteCohortGoal error:', error);
throw new Error(
`Failed to delete cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ─── Goal Assignments ────────────────────────────────────────────────────────
export async function upsertCohortGoalAssignments(rows: TNewCohortGoalAssignment[]): Promise<TCohortGoalAssignment[]> {
if (rows.length === 0) return [];
try {
return await db
.insert(schema.cohortGoalAssignment)
.values(rows)
.onConflictDoUpdate({
target: [schema.cohortGoalAssignment.goalId, schema.cohortGoalAssignment.cohortMemberId],
set: {
dueDate: sql`excluded.due_date`,
status: sql`excluded.status`,
completedCount: sql`excluded.completed_count`,
requiredCount: sql`excluded.required_count`,
completedAt: sql`excluded.completed_at`,
lastEvaluatedAt: sql`excluded.last_evaluated_at`,
updatedAt: sql`now()`
}
})
.returning();
} catch (error) {
console.error('upsertCohortGoalAssignments error:', error);
throw new Error(`Failed to upsert goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCohortGoalAssignments(goalId: string): Promise<TCohortGoalAssignment[]> {
try {
return await db.select().from(schema.cohortGoalAssignment).where(eq(schema.cohortGoalAssignment.goalId, goalId));
} catch (error) {
console.error('getCohortGoalAssignments error:', error);
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssignmentsForCohortMembers(cohortMemberIds: string[]): Promise<TCohortGoalAssignment[]> {
if (cohortMemberIds.length === 0) return [];
try {
return await db
.select()
.from(schema.cohortGoalAssignment)
.where(inArray(schema.cohortGoalAssignment.cohortMemberId, cohortMemberIds));
} catch (error) {
console.error('getAssignmentsForCohortMembers error:', error);
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssignmentsForProfile(profileId: string): Promise<
Array<
TCohortGoalAssignment & {
goal: TCohortGoal;
cohortId: string;
cohortName: string;
}
>
> {
try {
const result = await db
.select({
assignment: schema.cohortGoalAssignment,
goal: schema.cohortGoal,
cohortId: schema.cohort.id,
cohortName: schema.cohort.name
})
.from(schema.cohortGoalAssignment)
.innerJoin(schema.cohortMember, eq(schema.cohortGoalAssignment.cohortMemberId, schema.cohortMember.id))
.innerJoin(schema.cohortGoal, eq(schema.cohortGoalAssignment.goalId, schema.cohortGoal.id))
.innerJoin(schema.cohort, eq(schema.cohortGoal.cohortId, schema.cohort.id))
.where(and(eq(schema.cohortMember.profileId, profileId), eq(schema.cohortGoal.status, 'active')));
return result.map((row) => ({
...row.assignment,
goal: row.goal,
cohortId: row.cohortId,
cohortName: row.cohortName
}));
} catch (error) {
console.error('getAssignmentsForProfile error:', error);
throw new Error(
`Failed to get goal assignments for profile: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Per-goal status counts for a cohort (used by dashboard tiles).
*/
export async function countAssignmentsByStatus(goalIds: string[]): Promise<Map<string, Record<string, number>>> {
if (goalIds.length === 0) return new Map();
try {
const rows = await db
.select({
goalId: schema.cohortGoalAssignment.goalId,
status: schema.cohortGoalAssignment.status,
count: sql<number>`count(*)::int`.as('count')
})
.from(schema.cohortGoalAssignment)
.where(inArray(schema.cohortGoalAssignment.goalId, goalIds))
.groupBy(schema.cohortGoalAssignment.goalId, schema.cohortGoalAssignment.status);
const map = new Map<string, Record<string, number>>();
for (const row of rows) {
const bucket = map.get(row.goalId) ?? {};
bucket[row.status] = Number(row.count) || 0;
map.set(row.goalId, bucket);
}
return map;
} catch (error) {
console.error('countAssignmentsByStatus error:', error);
throw new Error(`Failed to count goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// ─── Evaluator Inputs ────────────────────────────────────────────────────────
/**
* For each (profile, course) pair, return the most recent course_completion_record
* (cycle-aware compliance status) plus optional score. Used by the goal evaluator.
*/
export async function getLatestCompletionRecordsForProfilesAndCourses(
profileIds: string[],
courseIds: string[]
): Promise<
Array<{ profileId: string; courseId: string; status: string; score: number | null; completedAt: string | null }>
> {
if (profileIds.length === 0 || courseIds.length === 0) return [];
try {
const rows = await db
.select({
profileId: schema.courseCompletionRecord.profileId,
courseId: schema.courseCompletionRecord.courseId,
status: schema.courseCompletionRecord.status,
score: schema.courseCompletionRecord.score,
completedAt: schema.courseCompletionRecord.completedAt,
cycleNumber: schema.courseCompletionRecord.cycleNumber
})
.from(schema.courseCompletionRecord)
.where(
and(
inArray(schema.courseCompletionRecord.profileId, profileIds),
inArray(schema.courseCompletionRecord.courseId, courseIds)
)
);
const latest = new Map<string, (typeof rows)[number]>();
for (const row of rows) {
const key = `${row.profileId}:${row.courseId}`;
const prev = latest.get(key);
if (!prev || (row.cycleNumber ?? 0) > (prev.cycleNumber ?? 0)) {
latest.set(key, row);
}
}
return [...latest.values()].map((row) => ({
profileId: row.profileId,
courseId: row.courseId,
status: row.status,
score: row.score ?? null,
completedAt: row.completedAt ?? null
}));
} catch (error) {
console.error('getLatestCompletionRecordsForProfilesAndCourses error:', error);
throw new Error(`Failed to load completion records: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Fallback completion source for non-compliance courses: a learner is considered
* "done" with a course when every lesson in the course has a `lesson_completion`
* row marked completed. Returns set of (profileId, courseId) keys that match.
*/
export async function getNonComplianceCourseCompletions(
profileIds: string[],
courseIds: string[]
): Promise<Set<string>> {
if (profileIds.length === 0 || courseIds.length === 0) return new Set();
try {
const rows = await db
.select({
profileId: schema.lessonCompletion.profileId,
courseId: schema.lesson.courseId,
totalLessons: sql<number>`COUNT(DISTINCT ${schema.lesson.id})::int`.as('totalLessons'),
completedLessons: sql<number>`
COUNT(DISTINCT CASE
WHEN ${schema.lessonCompletion.isComplete} = true THEN ${schema.lessonCompletion.lessonId}
ELSE NULL
END)::int
`.as('completedLessons')
})
.from(schema.lesson)
.leftJoin(
schema.lessonCompletion,
and(
eq(schema.lessonCompletion.lessonId, schema.lesson.id),
inArray(schema.lessonCompletion.profileId, profileIds)
)
)
.where(inArray(schema.lesson.courseId, courseIds))
.groupBy(schema.lessonCompletion.profileId, schema.lesson.courseId);
const completed = new Set<string>();
for (const row of rows) {
if (!row.profileId || !row.courseId) continue;
if (row.totalLessons > 0 && row.completedLessons >= row.totalLessons) {
completed.add(`${row.profileId}:${row.courseId}`);
}
}
return completed;
} catch (error) {
console.error('getNonComplianceCourseCompletions error:', error);
throw new Error(`Failed to load lesson completions: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* For score / pass_rate goals: pull the highest score per (profile, course)
* across compliance records (which already store the assessment score).
*/
export async function getMaxScoresForProfilesAndCourses(
profileIds: string[],
courseIds: string[]
): Promise<Map<string, number>> {
if (profileIds.length === 0 || courseIds.length === 0) return new Map();
try {
const rows = await db
.select({
profileId: schema.courseCompletionRecord.profileId,
courseId: schema.courseCompletionRecord.courseId,
maxScore: sql<number | null>`MAX(${schema.courseCompletionRecord.score})`.as('maxScore')
})
.from(schema.courseCompletionRecord)
.where(
and(
inArray(schema.courseCompletionRecord.profileId, profileIds),
inArray(schema.courseCompletionRecord.courseId, courseIds)
)
)
.groupBy(schema.courseCompletionRecord.profileId, schema.courseCompletionRecord.courseId);
const map = new Map<string, number>();
for (const row of rows) {
if (row.maxScore == null) continue;
map.set(`${row.profileId}:${row.courseId}`, Number(row.maxScore));
}
return map;
} catch (error) {
console.error('getMaxScoresForProfilesAndCourses error:', error);
throw new Error(`Failed to load scores: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './cohort';
export * from './goal';