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:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
@@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,85 +6,85 @@ import { db } from '@db/drizzle';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TProgramGoal = typeof schema.programGoal.$inferSelect;
|
||||
export type TNewProgramGoal = typeof schema.programGoal.$inferInsert;
|
||||
export type TProgramGoalAssignment = typeof schema.programGoalAssignment.$inferSelect;
|
||||
export type TNewProgramGoalAssignment = typeof schema.programGoalAssignment.$inferInsert;
|
||||
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 createProgramGoal(data: TNewProgramGoal): Promise<TProgramGoal> {
|
||||
export async function createCohortGoal(data: TNewCohortGoal): Promise<TCohortGoal> {
|
||||
try {
|
||||
const [goal] = await db.insert(schema.programGoal).values(data).returning();
|
||||
if (!goal) throw new Error('Failed to create program goal');
|
||||
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('createProgramGoal error:', error);
|
||||
throw new Error(`Failed to create program goal: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
console.error('createCohortGoal error:', error);
|
||||
throw new Error(`Failed to create cohort goal: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalById(goalId: string): Promise<TProgramGoal | null> {
|
||||
export async function getCohortGoalById(goalId: string): Promise<TCohortGoal | null> {
|
||||
try {
|
||||
const [goal] = await db.select().from(schema.programGoal).where(eq(schema.programGoal.id, goalId)).limit(1);
|
||||
const [goal] = await db.select().from(schema.cohortGoal).where(eq(schema.cohortGoal.id, goalId)).limit(1);
|
||||
|
||||
return goal ?? null;
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalById error:', error);
|
||||
console.error('getCohortGoalById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
`Failed to get cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoals(
|
||||
programId: string,
|
||||
export async function getCohortGoals(
|
||||
cohortId: string,
|
||||
options: { includeArchived?: boolean } = {}
|
||||
): Promise<TProgramGoal[]> {
|
||||
): Promise<TCohortGoal[]> {
|
||||
try {
|
||||
const conditions = [eq(schema.programGoal.programId, programId)];
|
||||
const conditions = [eq(schema.cohortGoal.cohortId, cohortId)];
|
||||
if (!options.includeArchived) {
|
||||
conditions.push(eq(schema.programGoal.status, 'active'));
|
||||
conditions.push(eq(schema.cohortGoal.status, 'active'));
|
||||
}
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.programGoal)
|
||||
.from(schema.cohortGoal)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(schema.programGoal.createdAt));
|
||||
.orderBy(asc(schema.cohortGoal.createdAt));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoals error:', error);
|
||||
throw new Error(`Failed to list program goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
console.error('getCohortGoals error:', error);
|
||||
throw new Error(`Failed to list cohort goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalsByOrg(
|
||||
export async function getCohortGoalsByOrg(
|
||||
organizationId: string
|
||||
): Promise<Array<TProgramGoal & { programId: string; programName: string }>> {
|
||||
): Promise<Array<TCohortGoal & { cohortId: string; cohortName: string }>> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
goal: schema.programGoal,
|
||||
programName: schema.program.name
|
||||
goal: schema.cohortGoal,
|
||||
cohortName: schema.cohort.name
|
||||
})
|
||||
.from(schema.programGoal)
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.where(and(eq(schema.program.organizationId, organizationId), eq(schema.programGoal.status, 'active')));
|
||||
.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, programName: row.programName }));
|
||||
return result.map((row) => ({ ...row.goal, cohortName: row.cohortName }));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalsByOrg error:', 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 listAllActiveProgramGoals(): Promise<TProgramGoal[]> {
|
||||
export async function listAllActiveCohortGoals(): Promise<TCohortGoal[]> {
|
||||
try {
|
||||
return await db.select().from(schema.programGoal).where(eq(schema.programGoal.status, 'active'));
|
||||
return await db.select().from(schema.cohortGoal).where(eq(schema.cohortGoal.status, 'active'));
|
||||
} catch (error) {
|
||||
console.error('listAllActiveProgramGoals error:', error);
|
||||
console.error('listAllActiveCohortGoals error:', error);
|
||||
throw new Error(`Failed to list active goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
@@ -96,12 +96,12 @@ export async function listAllActiveProgramGoals(): Promise<TProgramGoal[]> {
|
||||
*/
|
||||
export async function listAssignmentsForReminderScan(): Promise<
|
||||
Array<{
|
||||
assignment: TProgramGoalAssignment;
|
||||
goal: TProgramGoal;
|
||||
assignment: TCohortGoalAssignment;
|
||||
goal: TCohortGoal;
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
programId: string;
|
||||
programName: string;
|
||||
cohortId: string;
|
||||
cohortName: string;
|
||||
organizationName: string;
|
||||
organizationSiteName: string | null;
|
||||
organizationCustomDomain: string | null;
|
||||
@@ -113,12 +113,12 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
assignment: schema.programGoalAssignment,
|
||||
goal: schema.programGoal,
|
||||
profileId: schema.programMember.profileId,
|
||||
email: schema.programMember.email,
|
||||
programId: schema.program.id,
|
||||
programName: schema.program.name,
|
||||
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,
|
||||
@@ -126,15 +126,15 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
organizationAvatarUrl: schema.organization.avatarUrl,
|
||||
organizationTheme: schema.organization.theme
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
||||
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.innerJoin(schema.organization, eq(schema.program.organizationId, schema.organization.id))
|
||||
.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.programGoal.status, 'active'),
|
||||
inArray(schema.programGoalAssignment.status, ['not_started', 'in_progress', 'at_risk', 'overdue'])
|
||||
eq(schema.cohortGoal.status, 'active'),
|
||||
inArray(schema.cohortGoalAssignment.status, ['not_started', 'in_progress', 'at_risk', 'overdue'])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -145,8 +145,8 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
goal: row.goal,
|
||||
profileId: row.profileId!,
|
||||
email: row.email,
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
cohortId: row.cohortId,
|
||||
cohortName: row.cohortName,
|
||||
organizationName: row.organizationName,
|
||||
organizationSiteName: row.organizationSiteName,
|
||||
organizationCustomDomain: row.organizationCustomDomain,
|
||||
@@ -162,49 +162,47 @@ export async function listAssignmentsForReminderScan(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramGoal(goalId: string, data: Partial<TNewProgramGoal>): Promise<TProgramGoal | null> {
|
||||
export async function updateCohortGoal(goalId: string, data: Partial<TNewCohortGoal>): Promise<TCohortGoal | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.programGoal)
|
||||
.update(schema.cohortGoal)
|
||||
.set({ ...data, updatedAt: sql`now()` })
|
||||
.where(eq(schema.programGoal.id, goalId))
|
||||
.where(eq(schema.cohortGoal.id, goalId))
|
||||
.returning();
|
||||
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('updateProgramGoal error:', error);
|
||||
console.error('updateCohortGoal error:', error);
|
||||
throw new Error(
|
||||
`Failed to update program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
`Failed to update cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramGoal(goalId: string): Promise<TProgramGoal | null> {
|
||||
export async function deleteCohortGoal(goalId: string): Promise<TCohortGoal | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.programGoal).where(eq(schema.programGoal.id, goalId)).returning();
|
||||
const [deleted] = await db.delete(schema.cohortGoal).where(eq(schema.cohortGoal.id, goalId)).returning();
|
||||
|
||||
return deleted ?? null;
|
||||
} catch (error) {
|
||||
console.error('deleteProgramGoal error:', error);
|
||||
console.error('deleteCohortGoal error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
`Failed to delete cohort goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Goal Assignments ────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertProgramGoalAssignments(
|
||||
rows: TNewProgramGoalAssignment[]
|
||||
): Promise<TProgramGoalAssignment[]> {
|
||||
export async function upsertCohortGoalAssignments(rows: TNewCohortGoalAssignment[]): Promise<TCohortGoalAssignment[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.insert(schema.programGoalAssignment)
|
||||
.insert(schema.cohortGoalAssignment)
|
||||
.values(rows)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.programGoalAssignment.goalId, schema.programGoalAssignment.programMemberId],
|
||||
target: [schema.cohortGoalAssignment.goalId, schema.cohortGoalAssignment.cohortMemberId],
|
||||
set: {
|
||||
dueDate: sql`excluded.due_date`,
|
||||
status: sql`excluded.status`,
|
||||
@@ -217,62 +215,62 @@ export async function upsertProgramGoalAssignments(
|
||||
})
|
||||
.returning();
|
||||
} catch (error) {
|
||||
console.error('upsertProgramGoalAssignments error:', error);
|
||||
console.error('upsertCohortGoalAssignments error:', error);
|
||||
throw new Error(`Failed to upsert goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalAssignments(goalId: string): Promise<TProgramGoalAssignment[]> {
|
||||
export async function getCohortGoalAssignments(goalId: string): Promise<TCohortGoalAssignment[]> {
|
||||
try {
|
||||
return await db.select().from(schema.programGoalAssignment).where(eq(schema.programGoalAssignment.goalId, goalId));
|
||||
return await db.select().from(schema.cohortGoalAssignment).where(eq(schema.cohortGoalAssignment.goalId, goalId));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalAssignments error:', error);
|
||||
console.error('getCohortGoalAssignments error:', error);
|
||||
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignmentsForProgramMembers(programMemberIds: string[]): Promise<TProgramGoalAssignment[]> {
|
||||
if (programMemberIds.length === 0) return [];
|
||||
export async function getAssignmentsForCohortMembers(cohortMemberIds: string[]): Promise<TCohortGoalAssignment[]> {
|
||||
if (cohortMemberIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.programGoalAssignment)
|
||||
.where(inArray(schema.programGoalAssignment.programMemberId, programMemberIds));
|
||||
.from(schema.cohortGoalAssignment)
|
||||
.where(inArray(schema.cohortGoalAssignment.cohortMemberId, cohortMemberIds));
|
||||
} catch (error) {
|
||||
console.error('getAssignmentsForProgramMembers error:', 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<
|
||||
TProgramGoalAssignment & {
|
||||
goal: TProgramGoal;
|
||||
programId: string;
|
||||
programName: string;
|
||||
TCohortGoalAssignment & {
|
||||
goal: TCohortGoal;
|
||||
cohortId: string;
|
||||
cohortName: string;
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
assignment: schema.programGoalAssignment,
|
||||
goal: schema.programGoal,
|
||||
programId: schema.program.id,
|
||||
programName: schema.program.name
|
||||
assignment: schema.cohortGoalAssignment,
|
||||
goal: schema.cohortGoal,
|
||||
cohortId: schema.cohort.id,
|
||||
cohortName: schema.cohort.name
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
||||
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.where(and(eq(schema.programMember.profileId, profileId), eq(schema.programGoal.status, 'active')));
|
||||
.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,
|
||||
programId: row.programId,
|
||||
programName: row.programName
|
||||
cohortId: row.cohortId,
|
||||
cohortName: row.cohortName
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getAssignmentsForProfile error:', error);
|
||||
@@ -283,7 +281,7 @@ export async function getAssignmentsForProfile(profileId: string): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-goal status counts for a program (used by dashboard tiles).
|
||||
* 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();
|
||||
@@ -291,13 +289,13 @@ export async function countAssignmentsByStatus(goalIds: string[]): Promise<Map<s
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
goalId: schema.programGoalAssignment.goalId,
|
||||
status: schema.programGoalAssignment.status,
|
||||
goalId: schema.cohortGoalAssignment.goalId,
|
||||
status: schema.cohortGoalAssignment.status,
|
||||
count: sql<number>`count(*)::int`.as('count')
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.where(inArray(schema.programGoalAssignment.goalId, goalIds))
|
||||
.groupBy(schema.programGoalAssignment.goalId, schema.programGoalAssignment.status);
|
||||
.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) {
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './cohort';
|
||||
export * from './goal';
|
||||
@@ -15,7 +15,7 @@ export * from './newsfeed';
|
||||
export * from './submission';
|
||||
export * from './tag';
|
||||
export * from './course-import';
|
||||
export * from './program';
|
||||
export * from './cohort';
|
||||
export * from './widget';
|
||||
export * from './jobs';
|
||||
export * from './media-transcript';
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './program';
|
||||
export * from './goal';
|
||||
@@ -1,770 +0,0 @@
|
||||
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 TProgram = typeof schema.program.$inferSelect;
|
||||
export type TNewProgram = typeof schema.program.$inferInsert;
|
||||
export type TProgramMember = typeof schema.programMember.$inferSelect;
|
||||
export type TNewProgramMember = typeof schema.programMember.$inferInsert;
|
||||
export type TProgramCourse = typeof schema.programCourse.$inferSelect;
|
||||
export type TProgramNewsfeed = typeof schema.programNewsfeed.$inferSelect;
|
||||
export type TNewProgramNewsfeed = typeof schema.programNewsfeed.$inferInsert;
|
||||
export type TProgramNewsfeedComment = typeof schema.programNewsfeedComment.$inferSelect;
|
||||
export type TNewProgramNewsfeedComment = typeof schema.programNewsfeedComment.$inferInsert;
|
||||
|
||||
// ─── Program CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createProgram(data: TNewProgram): Promise<TProgram> {
|
||||
try {
|
||||
const [program] = await db.insert(schema.program).values(data).returning();
|
||||
if (!program) throw new Error('Failed to create program');
|
||||
return program;
|
||||
} catch (error) {
|
||||
console.error('createProgram error:', error);
|
||||
throw new Error(`Failed to create program: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Inserts the program and adds `creatorProfileId` as a program team member (tutor) in one transaction. */
|
||||
export async function createProgramWithCreatorMembership(
|
||||
programData: TNewProgram,
|
||||
creatorProfileId: string
|
||||
): Promise<TProgram> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [program] = await tx.insert(schema.program).values(programData).returning();
|
||||
if (!program) {
|
||||
throw new Error('Failed to create program');
|
||||
}
|
||||
|
||||
await tx.insert(schema.programMember).values({
|
||||
programId: program.id,
|
||||
profileId: creatorProfileId,
|
||||
roleId: ROLE.TUTOR
|
||||
});
|
||||
|
||||
return program;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('createProgramWithCreatorMembership error:', error);
|
||||
throw new Error(`Failed to create program: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramById(programId: string): Promise<TProgram | null> {
|
||||
try {
|
||||
const [program] = await db.select().from(schema.program).where(eq(schema.program.id, programId)).limit(1);
|
||||
return program || null;
|
||||
} catch (error) {
|
||||
console.error('getProgramById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramsByOrg(
|
||||
organizationId: string,
|
||||
programIds?: string[]
|
||||
): Promise<Array<TProgram & { courseCount: number; studentCount: number }>> {
|
||||
try {
|
||||
const whereCondition =
|
||||
programIds && programIds.length > 0
|
||||
? and(eq(schema.program.organizationId, organizationId), inArray(schema.program.id, programIds))
|
||||
: eq(schema.program.organizationId, organizationId);
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
program: schema.program,
|
||||
courseCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.programCourse}
|
||||
WHERE ${eq(schema.programCourse.programId, schema.program.id)}),
|
||||
0
|
||||
)
|
||||
`.as('courseCount'),
|
||||
studentCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.programMember}
|
||||
WHERE ${and(
|
||||
eq(schema.programMember.programId, schema.program.id),
|
||||
eq(schema.programMember.roleId, ROLE.STUDENT)
|
||||
)}),
|
||||
0
|
||||
)
|
||||
`.as('studentCount')
|
||||
})
|
||||
.from(schema.program)
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(schema.program.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.program,
|
||||
courseCount: Number(row.courseCount || 0),
|
||||
studentCount: Number(row.studentCount || 0)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getProgramsByOrg error:', error);
|
||||
throw new Error(
|
||||
`Failed to get programs for org "${organizationId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TSearchOrgProgram {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export async function searchOrgPrograms(orgId: string, search: string, limit: number): Promise<TSearchOrgProgram[]> {
|
||||
try {
|
||||
const searchValue = `%${search.trim()}%`;
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: schema.program.id,
|
||||
name: schema.program.name,
|
||||
description: schema.program.description,
|
||||
status: schema.program.status,
|
||||
updatedAt: schema.program.updatedAt
|
||||
})
|
||||
.from(schema.program)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.program.organizationId, orgId),
|
||||
eq(schema.program.status, 'ACTIVE'),
|
||||
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.program.updatedAt))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('searchOrgPrograms error:', error);
|
||||
throw new Error(`Failed to search org programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchLmsPrograms(
|
||||
orgId: string,
|
||||
profileId: string,
|
||||
search: string,
|
||||
limit: number
|
||||
): Promise<TSearchOrgProgram[]> {
|
||||
try {
|
||||
const searchValue = `%${search.trim()}%`;
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: schema.program.id,
|
||||
name: schema.program.name,
|
||||
description: schema.program.description,
|
||||
status: schema.program.status,
|
||||
updatedAt: schema.program.updatedAt
|
||||
})
|
||||
.from(schema.programMember)
|
||||
.innerJoin(schema.program, eq(schema.programMember.programId, schema.program.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.programMember.profileId, profileId),
|
||||
eq(schema.program.organizationId, orgId),
|
||||
eq(schema.program.status, 'ACTIVE'),
|
||||
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.program.updatedAt))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('searchLmsPrograms error:', error);
|
||||
throw new Error(`Failed to search LMS programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExistingProgramMembers(
|
||||
pairs: Array<{ programId: string; profileId: string }>
|
||||
): Promise<Set<string>> {
|
||||
if (pairs.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const programIds = [...new Set(pairs.map((pair) => pair.programId))];
|
||||
const profileIds = [...new Set(pairs.map((pair) => pair.profileId))];
|
||||
|
||||
const rows = await db
|
||||
.select({ programId: schema.programMember.programId, profileId: schema.programMember.profileId })
|
||||
.from(schema.programMember)
|
||||
.where(
|
||||
and(inArray(schema.programMember.programId, programIds), inArray(schema.programMember.profileId, profileIds))
|
||||
);
|
||||
|
||||
return new Set(
|
||||
rows
|
||||
.filter((row): row is { programId: string; profileId: string } => Boolean(row.programId && row.profileId))
|
||||
.map((row) => `${row.programId}:${row.profileId}`)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('getExistingProgramMembers error:', error);
|
||||
throw new Error(
|
||||
`Failed to check existing program members: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgram(programId: string, data: Partial<TNewProgram>): Promise<TProgram | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.program)
|
||||
.set({ ...data, updatedAt: sql`now()` })
|
||||
.where(eq(schema.program.id, programId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateProgram error:', error);
|
||||
throw new Error(
|
||||
`Failed to update program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgram(programId: string): Promise<TProgram | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.program).where(eq(schema.program.id, programId)).returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('deleteProgram error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Membership ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getProgramMemberByProfileId(
|
||||
programId: string,
|
||||
profileId: string
|
||||
): Promise<TProgramMember | null> {
|
||||
try {
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(schema.programMember)
|
||||
.where(and(eq(schema.programMember.programId, programId), eq(schema.programMember.profileId, profileId)))
|
||||
.limit(1);
|
||||
return member || null;
|
||||
} catch (error) {
|
||||
console.error('getProgramMemberByProfileId error:', error);
|
||||
throw new Error(`Failed to get program member: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isProgramMember(programId: string, profileId: string): Promise<boolean> {
|
||||
try {
|
||||
const member = await getProgramMemberByProfileId(programId, profileId);
|
||||
return member !== null;
|
||||
} catch (error) {
|
||||
console.error('isProgramMember error:', error);
|
||||
throw new Error(`Failed to check program membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramMemberRole(programId: string, profileId: string): Promise<number | null> {
|
||||
try {
|
||||
const member = await getProgramMemberByProfileId(programId, profileId);
|
||||
return member?.roleId || null;
|
||||
} catch (error) {
|
||||
console.error('getProgramMemberRole error:', error);
|
||||
throw new Error(`Failed to get program member role: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isOrgAdminByProgramId(programId: string, profileId: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({ orgMemberId: schema.organizationmember.id })
|
||||
.from(schema.program)
|
||||
.innerJoin(
|
||||
schema.organizationmember,
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, schema.program.organizationId),
|
||||
eq(schema.organizationmember.profileId, profileId),
|
||||
eq(schema.organizationmember.roleId, ROLE.ADMIN)
|
||||
)
|
||||
)
|
||||
.where(eq(schema.program.id, programId))
|
||||
.limit(1);
|
||||
return result.length > 0;
|
||||
} catch (error) {
|
||||
console.error('isOrgAdminByProgramId error:', error);
|
||||
throw new Error(`Failed to check org admin status: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProgramMember(data: TNewProgramMember, dbClient: DbOrTxClient = db): Promise<TProgramMember> {
|
||||
try {
|
||||
const [member] = await dbClient.insert(schema.programMember).values(data).returning();
|
||||
if (!member) throw new Error('Failed to add program member');
|
||||
return member;
|
||||
} catch (error) {
|
||||
console.error('addProgramMember error:', error);
|
||||
throw new Error(`Failed to add program member: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProgramMember(memberId: string): Promise<TProgramMember | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.programMember).where(eq(schema.programMember.id, memberId)).returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('removeProgramMember error:', error);
|
||||
throw new Error(
|
||||
`Failed to remove program member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramMember(
|
||||
memberId: string,
|
||||
data: Partial<TNewProgramMember>
|
||||
): Promise<TProgramMember | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.programMember)
|
||||
.set(data)
|
||||
.where(eq(schema.programMember.id, memberId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateProgramMember error:', error);
|
||||
throw new Error(
|
||||
`Failed to update program member "${memberId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramMembers(programId: string): Promise<
|
||||
Array<
|
||||
TProgramMember & {
|
||||
profile: {
|
||||
id: string;
|
||||
fullname: string | null;
|
||||
username: string | null;
|
||||
avatarUrl: string | null;
|
||||
email: string | null;
|
||||
} | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
member: schema.programMember,
|
||||
profile: {
|
||||
id: schema.profile.id,
|
||||
fullname: schema.profile.fullname,
|
||||
username: schema.profile.username,
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
email: schema.profile.email
|
||||
}
|
||||
})
|
||||
.from(schema.programMember)
|
||||
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
|
||||
.where(eq(schema.programMember.programId, programId))
|
||||
.orderBy(asc(schema.programMember.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.member,
|
||||
profile: row.profile?.id ? row.profile : null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getProgramMembers error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program members for "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEnrolledProgramsByProfile(
|
||||
profileId: string
|
||||
): Promise<Array<TProgram & { roleId: number; courseCount: number; studentCount: number }>> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
program: schema.program,
|
||||
roleId: schema.programMember.roleId,
|
||||
courseCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.programCourse}
|
||||
INNER JOIN ${schema.course}
|
||||
ON ${schema.programCourse.courseId} = ${schema.course.id}
|
||||
WHERE ${schema.programCourse.programId} = ${schema.program.id}
|
||||
AND (
|
||||
${schema.programMember.roleId} <> ${ROLE.STUDENT}
|
||||
OR ${schema.course.isPublished} = true
|
||||
)),
|
||||
0
|
||||
)
|
||||
`.as('courseCount'),
|
||||
studentCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.programMember} AS student_member
|
||||
WHERE student_member.program_id = ${schema.program.id}
|
||||
AND student_member.role_id = ${ROLE.STUDENT}),
|
||||
0
|
||||
)
|
||||
`.as('studentCount')
|
||||
})
|
||||
.from(schema.programMember)
|
||||
.innerJoin(schema.program, eq(schema.programMember.programId, schema.program.id))
|
||||
.where(eq(schema.programMember.profileId, profileId))
|
||||
.orderBy(desc(schema.program.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.program,
|
||||
roleId: row.roleId,
|
||||
courseCount: Number(row.courseCount || 0),
|
||||
studentCount: Number(row.studentCount || 0)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getEnrolledProgramsByProfile error:', error);
|
||||
throw new Error(
|
||||
`Failed to get enrolled programs for profile "${profileId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Courses ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function addCourseToProgram(programId: string, courseId: string): Promise<TProgramCourse> {
|
||||
try {
|
||||
const [row] = await db.insert(schema.programCourse).values({ programId, courseId }).returning();
|
||||
if (!row) throw new Error('Failed to add course to program');
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('addCourseToProgram error:', error);
|
||||
throw new Error(`Failed to add course to program: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCourseFromProgram(programId: string, courseId: string): Promise<TProgramCourse | null> {
|
||||
try {
|
||||
const [deleted] = await db
|
||||
.delete(schema.programCourse)
|
||||
.where(and(eq(schema.programCourse.programId, programId), eq(schema.programCourse.courseId, courseId)))
|
||||
.returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('removeCourseFromProgram error:', error);
|
||||
throw new Error(
|
||||
`Failed to remove course from program: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCoursesByProgram(
|
||||
programId: string,
|
||||
onlyPublished = false
|
||||
): Promise<
|
||||
Array<
|
||||
TProgramCourse & {
|
||||
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.programCourse.programId, programId), eq(schema.course.isPublished, true))
|
||||
: eq(schema.programCourse.programId, programId);
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
programCourse: schema.programCourse,
|
||||
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.programCourse)
|
||||
.innerJoin(schema.course, eq(schema.programCourse.courseId, schema.course.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(asc(schema.programCourse.addedAt));
|
||||
|
||||
return result.map((row) => ({ ...row.programCourse, course: row.course }));
|
||||
} catch (error) {
|
||||
console.error('getCoursesByProgram error:', error);
|
||||
throw new Error(
|
||||
`Failed to get courses for program "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isProgramCourse(programId: string, courseId: string): Promise<boolean> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ id: schema.programCourse.id })
|
||||
.from(schema.programCourse)
|
||||
.where(and(eq(schema.programCourse.programId, programId), eq(schema.programCourse.courseId, courseId)))
|
||||
.limit(1);
|
||||
return !!row;
|
||||
} catch (error) {
|
||||
console.error('isProgramCourse error:', error);
|
||||
throw new Error(`Failed to check program course: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Newsfeed ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getProgramNewsfeed(
|
||||
programId: string,
|
||||
options: { cursor?: string; limit: number }
|
||||
): Promise<{
|
||||
items: Array<
|
||||
TProgramNewsfeed & {
|
||||
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.programNewsfeed.programId, programId)];
|
||||
if (cursor) {
|
||||
whereConditions.push(sql`${schema.programNewsfeed.createdAt} < ${cursor}`);
|
||||
}
|
||||
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql<number>`count(*)`.as('count') })
|
||||
.from(schema.programNewsfeed)
|
||||
.where(eq(schema.programNewsfeed.programId, programId));
|
||||
const totalCount = Number(totalCountResult[0]?.count || 0);
|
||||
|
||||
const feeds = await db
|
||||
.select({
|
||||
feed: schema.programNewsfeed,
|
||||
profile: schema.profile,
|
||||
commentCount: sql<number>`
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.programNewsfeedComment}
|
||||
WHERE ${eq(schema.programNewsfeedComment.programNewsfeedId, schema.programNewsfeed.id)}),
|
||||
0
|
||||
)
|
||||
`.as('commentCount')
|
||||
})
|
||||
.from(schema.programNewsfeed)
|
||||
.leftJoin(schema.programMember, eq(schema.programNewsfeed.authorId, schema.programMember.id))
|
||||
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
|
||||
.where(and(...whereConditions))
|
||||
.orderBy(desc(schema.programNewsfeed.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('getProgramNewsfeed error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program newsfeed "${programId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramNewsfeedById(feedId: string): Promise<TProgramNewsfeed | null> {
|
||||
try {
|
||||
const [feed] = await db.select().from(schema.programNewsfeed).where(eq(schema.programNewsfeed.id, feedId)).limit(1);
|
||||
return feed || null;
|
||||
} catch (error) {
|
||||
console.error('getProgramNewsfeedById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program newsfeed item "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProgramNewsfeed(data: TNewProgramNewsfeed): Promise<TProgramNewsfeed> {
|
||||
try {
|
||||
const [feed] = await db.insert(schema.programNewsfeed).values(data).returning();
|
||||
if (!feed) throw new Error('Failed to create program newsfeed');
|
||||
return feed;
|
||||
} catch (error) {
|
||||
console.error('createProgramNewsfeed error:', error);
|
||||
throw new Error(`Failed to create program newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramNewsfeed(
|
||||
feedId: string,
|
||||
data: Partial<TNewProgramNewsfeed>
|
||||
): Promise<TProgramNewsfeed | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.programNewsfeed)
|
||||
.set(data)
|
||||
.where(eq(schema.programNewsfeed.id, feedId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateProgramNewsfeed error:', error);
|
||||
throw new Error(
|
||||
`Failed to update program newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramNewsfeedReaction(
|
||||
feedId: string,
|
||||
reaction: { clap: string[]; smile: string[]; thumbsup: string[]; thumbsdown: string[] }
|
||||
): Promise<TProgramNewsfeed | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.programNewsfeed)
|
||||
.set({ reaction })
|
||||
.where(eq(schema.programNewsfeed.id, feedId))
|
||||
.returning();
|
||||
return updated || null;
|
||||
} catch (error) {
|
||||
console.error('updateProgramNewsfeedReaction error:', error);
|
||||
throw new Error(
|
||||
`Failed to update program newsfeed reaction "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramNewsfeed(feedId: string): Promise<TProgramNewsfeed | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.programNewsfeed).where(eq(schema.programNewsfeed.id, feedId)).returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('deleteProgramNewsfeed error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete program newsfeed "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Program Newsfeed Comments ────────────────────────────────────────────────
|
||||
|
||||
export async function getProgramNewsfeedComments(feedId: string): Promise<
|
||||
Array<
|
||||
TProgramNewsfeedComment & {
|
||||
authorProfileId: string | null;
|
||||
authorFullname: string | null;
|
||||
authorUsername: string | null;
|
||||
authorAvatarUrl: string | null;
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
comment: schema.programNewsfeedComment,
|
||||
profile: schema.profile
|
||||
})
|
||||
.from(schema.programNewsfeedComment)
|
||||
.leftJoin(schema.programMember, eq(schema.programNewsfeedComment.authorId, schema.programMember.id))
|
||||
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
|
||||
.where(eq(schema.programNewsfeedComment.programNewsfeedId, feedId))
|
||||
.orderBy(asc(schema.programNewsfeedComment.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('getProgramNewsfeedComments error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program newsfeed comments for "${feedId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProgramNewsfeedComment(data: TNewProgramNewsfeedComment): Promise<TProgramNewsfeedComment> {
|
||||
try {
|
||||
const [comment] = await db.insert(schema.programNewsfeedComment).values(data).returning();
|
||||
if (!comment) throw new Error('Failed to create program newsfeed comment');
|
||||
return comment;
|
||||
} catch (error) {
|
||||
console.error('createProgramNewsfeedComment error:', error);
|
||||
throw new Error(
|
||||
`Failed to create program newsfeed comment: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramNewsfeedComment(commentId: number): Promise<TProgramNewsfeedComment | null> {
|
||||
try {
|
||||
const [deleted] = await db
|
||||
.delete(schema.programNewsfeedComment)
|
||||
.where(eq(schema.programNewsfeedComment.id, commentId))
|
||||
.returning();
|
||||
return deleted || null;
|
||||
} catch (error) {
|
||||
console.error('deleteProgramNewsfeedComment error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete program newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramNewsfeedCommentById(commentId: number): Promise<TProgramNewsfeedComment | null> {
|
||||
try {
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(schema.programNewsfeedComment)
|
||||
.where(eq(schema.programNewsfeedComment.id, commentId))
|
||||
.limit(1);
|
||||
return comment || null;
|
||||
} catch (error) {
|
||||
console.error('getProgramNewsfeedCommentById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program newsfeed comment "${commentId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user