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
@@ -0,0 +1,114 @@
CREATE TYPE "public"."COHORT_GOAL_ASSIGNMENT_STATUS" AS ENUM('not_started', 'in_progress', 'completed', 'at_risk', 'overdue', 'waived');--> statement-breakpoint
CREATE TYPE "public"."COHORT_GOAL_DEADLINE_KIND" AS ENUM('absolute', 'relative_to_join', 'recurring', 'none');--> statement-breakpoint
CREATE TYPE "public"."COHORT_GOAL_STATUS" AS ENUM('active', 'archived');--> statement-breakpoint
CREATE TYPE "public"."COHORT_GOAL_TYPE" AS ENUM('complete_all', 'n_of_m', 'score', 'pass_rate', 'readiness');--> statement-breakpoint
CREATE TYPE "public"."COHORT_STATUS" AS ENUM('ACTIVE', 'INACTIVE', 'ARCHIVED');--> statement-breakpoint
CREATE TABLE "cohort" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now(),
"organization_id" uuid NOT NULL,
"name" varchar NOT NULL,
"description" text,
"cover_image" text,
"status" "COHORT_STATUS" DEFAULT 'ACTIVE' NOT NULL,
"created_by_profile_id" uuid
);
--> statement-breakpoint
CREATE TABLE "cohort_course" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cohort_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "cohort_course_cohort_id_course_id_unique" UNIQUE("cohort_id","course_id")
);
--> statement-breakpoint
CREATE TABLE "cohort_goal" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cohort_id" uuid NOT NULL,
"title" varchar(255) NOT NULL,
"description" text,
"type" "COHORT_GOAL_TYPE" NOT NULL,
"course_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
"required_count" integer,
"score_threshold" integer,
"team_pass_rate_threshold" integer,
"deadline_kind" "COHORT_GOAL_DEADLINE_KIND" DEFAULT 'none' NOT NULL,
"deadline_date" timestamp with time zone,
"relative_days" integer,
"recurring_months" integer,
"reminder_days_before" jsonb DEFAULT '[7,1]'::jsonb NOT NULL,
"status" "COHORT_GOAL_STATUS" DEFAULT 'active' NOT NULL,
"created_by_profile_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cohort_goal_assignment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"goal_id" uuid NOT NULL,
"cohort_member_id" uuid NOT NULL,
"due_date" timestamp with time zone,
"status" "COHORT_GOAL_ASSIGNMENT_STATUS" DEFAULT 'not_started' NOT NULL,
"completed_count" integer DEFAULT 0 NOT NULL,
"required_count" integer DEFAULT 0 NOT NULL,
"completed_at" timestamp with time zone,
"last_evaluated_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "cohort_goal_assignment_goal_id_cohort_member_id_unique" UNIQUE("goal_id","cohort_member_id")
);
--> statement-breakpoint
CREATE TABLE "cohort_member" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cohort_id" uuid NOT NULL,
"profile_id" uuid,
"role_id" integer NOT NULL,
"email" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "cohort_member_cohort_id_profile_id_unique" UNIQUE("cohort_id","profile_id")
);
--> statement-breakpoint
CREATE TABLE "cohort_newsfeed" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"author_id" uuid,
"cohort_id" uuid,
"content" text,
"reaction" jsonb DEFAULT '{"clap":[],"smile":[],"thumbsup":[],"thumbsdown":[]}'::jsonb,
"is_pinned" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cohort_newsfeed_comment" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "cohort_newsfeed_comment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"author_id" uuid,
"content" text,
"cohort_newsfeed_id" uuid
);
--> statement-breakpoint
ALTER TABLE "cohort" ADD CONSTRAINT "cohort_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort" ADD CONSTRAINT "cohort_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_course" ADD CONSTRAINT "cohort_course_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_course" ADD CONSTRAINT "cohort_course_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_goal" ADD CONSTRAINT "cohort_goal_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_goal" ADD CONSTRAINT "cohort_goal_created_by_profile_id_fkey" FOREIGN KEY ("created_by_profile_id") REFERENCES "public"."profile"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_goal_assignment" ADD CONSTRAINT "cohort_goal_assignment_goal_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "public"."cohort_goal"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_goal_assignment" ADD CONSTRAINT "cohort_goal_assignment_cohort_member_id_fkey" FOREIGN KEY ("cohort_member_id") REFERENCES "public"."cohort_member"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_member" ADD CONSTRAINT "cohort_member_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_member" ADD CONSTRAINT "cohort_member_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_member" ADD CONSTRAINT "cohort_member_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_newsfeed" ADD CONSTRAINT "cohort_newsfeed_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."cohort_member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_newsfeed" ADD CONSTRAINT "cohort_newsfeed_cohort_id_fkey" FOREIGN KEY ("cohort_id") REFERENCES "public"."cohort"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_newsfeed_comment" ADD CONSTRAINT "cohort_newsfeed_comment_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."cohort_member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cohort_newsfeed_comment" ADD CONSTRAINT "cohort_newsfeed_comment_cohort_newsfeed_id_fkey" FOREIGN KEY ("cohort_newsfeed_id") REFERENCES "public"."cohort_newsfeed"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_cohort_organization_id" ON "cohort" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "idx_cohort_course_cohort_id" ON "cohort_course" USING btree ("cohort_id");--> statement-breakpoint
CREATE INDEX "idx_cohort_goal_cohort_id" ON "cohort_goal" USING btree ("cohort_id");--> statement-breakpoint
CREATE INDEX "idx_cohort_goal_status" ON "cohort_goal" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_cohort_goal_assignment_goal_id" ON "cohort_goal_assignment" USING btree ("goal_id");--> statement-breakpoint
CREATE INDEX "idx_cohort_goal_assignment_cohort_member_id" ON "cohort_goal_assignment" USING btree ("cohort_member_id");--> statement-breakpoint
CREATE INDEX "idx_cohort_goal_assignment_due_date" ON "cohort_goal_assignment" USING btree ("due_date");--> statement-breakpoint
CREATE INDEX "idx_cohort_member_cohort_id" ON "cohort_member" USING btree ("cohort_id");--> statement-breakpoint
CREATE INDEX "idx_cohort_member_profile_id" ON "cohort_member" USING btree ("profile_id");--> statement-breakpoint
CREATE INDEX "idx_cohort_newsfeed_cohort_id" ON "cohort_newsfeed" USING btree ("cohort_id");
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,13 @@
"when": 1782684436805,
"tag": "0001_slow_lila_cheney",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1782660458258,
"tag": "0002_majestic_masked_marvel",
"breakpoints": true
}
]
}
+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'}`
);
}
}
@@ -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) {
+2
View File
@@ -0,0 +1,2 @@
export * from './cohort';
export * from './goal';
+1 -1
View File
@@ -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';
-2
View File
@@ -1,2 +0,0 @@
export * from './program';
export * from './goal';
-770
View File
@@ -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'}`
);
}
}
+251
View File
@@ -2962,6 +2962,257 @@ export const programGoalAssignment = pgTable(
]
);
// ─── Cohorts (successor to Programs; program tables retained for migration) ──
export const cohortStatus = pgEnum('COHORT_STATUS', ['ACTIVE', 'INACTIVE', 'ARCHIVED']);
export const cohort = pgTable(
'cohort',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
organizationId: uuid('organization_id').notNull(),
name: varchar().notNull(),
description: text(),
coverImage: text('cover_image'),
status: cohortStatus().default('ACTIVE').notNull(),
createdByProfileId: uuid('created_by_profile_id')
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'cohort_organization_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByProfileId],
foreignColumns: [profile.id],
name: 'cohort_created_by_profile_id_fkey'
}),
index('idx_cohort_organization_id').on(table.organizationId)
]
);
export const cohortCourse = pgTable(
'cohort_course',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
cohortId: uuid('cohort_id').notNull(),
courseId: uuid('course_id').notNull(),
addedAt: timestamp('added_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.cohortId],
foreignColumns: [cohort.id],
name: 'cohort_course_cohort_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'cohort_course_course_id_fkey'
}).onDelete('cascade'),
unique('cohort_course_cohort_id_course_id_unique').on(table.cohortId, table.courseId),
index('idx_cohort_course_cohort_id').on(table.cohortId)
]
);
export const cohortMember = pgTable(
'cohort_member',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
cohortId: uuid('cohort_id').notNull(),
profileId: uuid('profile_id'),
roleId: integer('role_id').notNull(),
email: text(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.cohortId],
foreignColumns: [cohort.id],
name: 'cohort_member_cohort_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'cohort_member_profile_id_fkey'
}),
foreignKey({
columns: [table.roleId],
foreignColumns: [role.id],
name: 'cohort_member_role_id_fkey'
}),
unique('cohort_member_cohort_id_profile_id_unique').on(table.cohortId, table.profileId),
index('idx_cohort_member_cohort_id').on(table.cohortId),
index('idx_cohort_member_profile_id').on(table.profileId)
]
);
export const cohortNewsfeed = pgTable(
'cohort_newsfeed',
{
id: uuid().defaultRandom().primaryKey().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
authorId: uuid('author_id'),
cohortId: uuid('cohort_id'),
content: text(),
reaction: jsonb()
.default({ clap: [], smile: [], thumbsup: [], thumbsdown: [] })
.$type<{ clap: string[]; smile: string[]; thumbsup: string[]; thumbsdown: string[] }>(),
isPinned: boolean('is_pinned').default(false).notNull()
},
(table) => [
foreignKey({
columns: [table.authorId],
foreignColumns: [cohortMember.id],
name: 'cohort_newsfeed_author_id_fkey'
}),
foreignKey({
columns: [table.cohortId],
foreignColumns: [cohort.id],
name: 'cohort_newsfeed_cohort_id_fkey'
}).onDelete('cascade'),
index('idx_cohort_newsfeed_cohort_id').on(table.cohortId)
]
);
export const cohortNewsfeedComment = pgTable(
'cohort_newsfeed_comment',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'cohort_newsfeed_comment_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
authorId: uuid('author_id'),
content: text(),
cohortNewsfeedId: uuid('cohort_newsfeed_id')
},
(table) => [
foreignKey({
columns: [table.authorId],
foreignColumns: [cohortMember.id],
name: 'cohort_newsfeed_comment_author_id_fkey'
}),
foreignKey({
columns: [table.cohortNewsfeedId],
foreignColumns: [cohortNewsfeed.id],
name: 'cohort_newsfeed_comment_cohort_newsfeed_id_fkey'
}).onDelete('cascade')
]
);
export const cohortGoalType = pgEnum('COHORT_GOAL_TYPE', ['complete_all', 'n_of_m', 'score', 'pass_rate', 'readiness']);
export const cohortGoalDeadlineKind = pgEnum('COHORT_GOAL_DEADLINE_KIND', [
'absolute',
'relative_to_join',
'recurring',
'none'
]);
export const cohortGoalStatus = pgEnum('COHORT_GOAL_STATUS', ['active', 'archived']);
export const cohortGoalAssignmentStatus = pgEnum('COHORT_GOAL_ASSIGNMENT_STATUS', [
'not_started',
'in_progress',
'completed',
'at_risk',
'overdue',
'waived'
]);
export const cohortGoal = pgTable(
'cohort_goal',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
cohortId: uuid('cohort_id').notNull(),
title: varchar({ length: 255 }).notNull(),
description: text(),
type: cohortGoalType().notNull(),
courseIds: jsonb('course_ids').$type<string[]>().default([]).notNull(),
requiredCount: integer('required_count'),
scoreThreshold: integer('score_threshold'),
teamPassRateThreshold: integer('team_pass_rate_threshold'),
deadlineKind: cohortGoalDeadlineKind('deadline_kind').default('none').notNull(),
deadlineDate: timestamp('deadline_date', { withTimezone: true, mode: 'string' }),
relativeDays: integer('relative_days'),
recurringMonths: integer('recurring_months'),
reminderDaysBefore: jsonb('reminder_days_before').$type<number[]>().default([7, 1]).notNull(),
status: cohortGoalStatus().default('active').notNull(),
createdByProfileId: uuid('created_by_profile_id'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.cohortId],
foreignColumns: [cohort.id],
name: 'cohort_goal_cohort_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByProfileId],
foreignColumns: [profile.id],
name: 'cohort_goal_created_by_profile_id_fkey'
}).onDelete('set null'),
index('idx_cohort_goal_cohort_id').on(table.cohortId),
index('idx_cohort_goal_status').on(table.status)
]
);
export const cohortGoalAssignment = pgTable(
'cohort_goal_assignment',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
goalId: uuid('goal_id').notNull(),
cohortMemberId: uuid('cohort_member_id').notNull(),
dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }),
status: cohortGoalAssignmentStatus().default('not_started').notNull(),
completedCount: integer('completed_count').default(0).notNull(),
requiredCount: integer('required_count').default(0).notNull(),
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
lastEvaluatedAt: timestamp('last_evaluated_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.goalId],
foreignColumns: [cohortGoal.id],
name: 'cohort_goal_assignment_goal_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.cohortMemberId],
foreignColumns: [cohortMember.id],
name: 'cohort_goal_assignment_cohort_member_id_fkey'
}).onDelete('cascade'),
unique('cohort_goal_assignment_goal_id_cohort_member_id_unique').on(table.goalId, table.cohortMemberId),
index('idx_cohort_goal_assignment_goal_id').on(table.goalId),
index('idx_cohort_goal_assignment_cohort_member_id').on(table.cohortMemberId),
index('idx_cohort_goal_assignment_due_date').on(table.dueDate)
]
);
// ─── AI Token Usage ──────────────────────────────────────────────────────────
export const aiTokenUsage = pgTable(
@@ -0,0 +1,229 @@
/**
* One-time data migration: copy all rows from legacy `program*` tables into the new
* `cohort*` tables while preserving primary keys and relationships.
*
* Prerequisites:
* 1. Run Drizzle migrations through `0002_majestic_masked_marvel` so cohort tables exist.
* 2. Back up the database before running in production.
*
* The legacy `program*` tables are left untouched for rollback safety.
* Re-running clears all cohort tables first, then copies a fresh snapshot from program tables.
*/
import { sql } from 'drizzle-orm';
import { db } from '../drizzle';
type TableCopy = {
label: string;
source: string;
target: string;
columnMap?: Record<string, string>;
/** Cast legacy PROGRAM_* enum columns to matching COHORT_* enum types. */
enumCasts?: Record<string, string>;
};
const TABLE_COPIES: TableCopy[] = [
{
label: 'cohort',
source: 'program',
target: 'cohort',
enumCasts: { status: 'COHORT_STATUS' }
},
{
label: 'cohort_course',
source: 'program_course',
target: 'cohort_course',
columnMap: { program_id: 'cohort_id' }
},
{
label: 'cohort_member',
source: 'program_member',
target: 'cohort_member',
columnMap: { program_id: 'cohort_id' }
},
{
label: 'cohort_newsfeed',
source: 'program_newsfeed',
target: 'cohort_newsfeed',
columnMap: { program_id: 'cohort_id' }
},
{
label: 'cohort_newsfeed_comment',
source: 'program_newsfeed_comment',
target: 'cohort_newsfeed_comment',
columnMap: { program_newsfeed_id: 'cohort_newsfeed_id' }
},
{
label: 'cohort_goal',
source: 'program_goal',
target: 'cohort_goal',
columnMap: { program_id: 'cohort_id' },
enumCasts: {
type: 'COHORT_GOAL_TYPE',
deadline_kind: 'COHORT_GOAL_DEADLINE_KIND',
status: 'COHORT_GOAL_STATUS'
}
},
{
label: 'cohort_goal_assignment',
source: 'program_goal_assignment',
target: 'cohort_goal_assignment',
columnMap: { program_member_id: 'cohort_member_id' },
enumCasts: { status: 'COHORT_GOAL_ASSIGNMENT_STATUS' }
}
];
/** Child tables first; TRUNCATE … CASCADE clears the full cohort graph. */
const COHORT_TABLES = [
'cohort_goal_assignment',
'cohort_newsfeed_comment',
'cohort_newsfeed',
'cohort_goal',
'cohort_course',
'cohort_member',
'cohort'
] as const;
async function clearCohortTables(): Promise<void> {
const tableList = COHORT_TABLES.map((table) => `"${table}"`).join(', ');
await db.execute(sql.raw(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`));
console.log(` Cleared cohort tables: ${COHORT_TABLES.join(', ')}`);
}
async function getTableColumns(tableName: string): Promise<string[]> {
const result = await db.execute<{ column_name: string }>(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ${tableName}
ORDER BY ordinal_position
`);
const rows = Array.isArray(result) ? result : ((result as { rows?: { column_name: string }[] }).rows ?? []);
return rows.map((row) => row.column_name);
}
async function getTableRowCount(tableName: string): Promise<number> {
const result = await db.execute<{ count: number }>(sql.raw(`SELECT COUNT(*)::int AS count FROM "${tableName}"`));
const rows = Array.isArray(result) ? result : ((result as { rows?: { count: number }[] }).rows ?? []);
return Number(rows[0]?.count ?? 0);
}
async function copyTable({ label, source, target, columnMap = {}, enumCasts = {} }: TableCopy): Promise<number> {
const sourceColumns = await getTableColumns(source);
const targetColumns = await getTableColumns(target);
if (sourceColumns.length === 0) {
throw new Error(`Source table "${source}" not found or has no columns`);
}
if (targetColumns.length === 0) {
throw new Error(`Target table "${target}" not found or has no columns`);
}
const insertColumns = sourceColumns.map((column) => columnMap[column] ?? column);
const missingTargetColumns = insertColumns.filter((column) => !targetColumns.includes(column));
if (missingTargetColumns.length > 0) {
throw new Error(`Target table "${target}" is missing columns: ${missingTargetColumns.join(', ')}`);
}
const selectList = sourceColumns
.map((column) => {
const outputColumn = columnMap[column] ?? column;
const sourceExpr = enumCasts[column] ? `"${column}"::text::"${enumCasts[column]}"` : `"${column}"`;
return outputColumn === column ? sourceExpr : `${sourceExpr} AS "${outputColumn}"`;
})
.join(', ');
const insertList = insertColumns.map((column) => `"${column}"`).join(', ');
const sourceCount = await getTableRowCount(source);
await db.execute(
sql.raw(`
INSERT INTO "${target}" (${insertList})
SELECT ${selectList}
FROM "${source}"
`)
);
const copiedCount = await getTableRowCount(target);
if (sourceCount !== copiedCount) {
throw new Error(
`Row count mismatch for ${label}: expected ${sourceCount} from "${source}", found ${copiedCount} in "${target}"`
);
}
console.log(` ${label}: copied ${copiedCount} row(s)`);
return copiedCount;
}
async function migrateInviteMetadata(): Promise<number> {
const countResult = await db.execute<{ count: number }>(sql`
SELECT COUNT(*)::int AS count
FROM organization_invite
WHERE metadata ? 'programIds'
OR metadata ? 'program_ids'
`);
const countRows = Array.isArray(countResult)
? countResult
: ((countResult as { rows?: { count: number }[] }).rows ?? []);
const invitesToUpdate = Number(countRows[0]?.count ?? 0);
if (invitesToUpdate === 0) {
console.log(' organization_invite metadata: updated 0 row(s)');
return 0;
}
await db.execute(sql`
UPDATE organization_invite
SET metadata = (
metadata
- 'programIds'
- 'program_ids'
) || jsonb_build_object(
'cohortIds',
COALESCE(metadata->'cohortIds', metadata->'programIds', metadata->'program_ids', '[]'::jsonb)
)
WHERE metadata ? 'programIds'
OR metadata ? 'program_ids'
`);
console.log(` organization_invite metadata: updated ${invitesToUpdate} row(s)`);
return invitesToUpdate;
}
async function migrate() {
console.log('Starting program → cohort data migration...\n');
console.log('Clearing existing cohort data...');
await clearCohortTables();
console.log('');
let totalCopied = 0;
for (const tableCopy of TABLE_COPIES) {
totalCopied += await copyTable(tableCopy);
}
console.log('');
await migrateInviteMetadata();
console.log(`\nDone. Copied ${totalCopied} total row(s) into cohort tables.`);
console.log('Legacy program tables were not modified or dropped.');
}
migrate()
.then(() => process.exit(0))
.catch((error) => {
console.error('migrate-programs-to-cohorts failed:', error);
process.exit(1);
});