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,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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user