feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -17,7 +17,7 @@ const globalDatabase = globalThis as typeof globalThis & {
|
||||
|
||||
function createDatabaseClient() {
|
||||
return postgres(connectionString, {
|
||||
max: process.env.NODE_ENV === 'production' ? 10 : 1,
|
||||
max: Number.parseInt(process.env.DATABASE_POOL_MAX ?? '10', 10) || 5,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
max_lifetime: 60 * 30
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TNewCourseSection,
|
||||
TProfile
|
||||
} from '@db/types';
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
@@ -23,6 +23,7 @@ import { getCourseContentItems, type CourseContentItemRow } from './content';
|
||||
*/
|
||||
export interface TBaseCourse extends TCourse {
|
||||
lessonCount: number;
|
||||
exerciseCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,6 @@ export interface TAdminCourse extends TBaseCourse {
|
||||
*/
|
||||
export interface TStudentCourse extends TBaseCourse {
|
||||
progressRate: number;
|
||||
exerciseCount: number;
|
||||
exercisesCompleted: number;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,11 @@ export interface TStudentCourse extends TBaseCourse {
|
||||
* @param siteName Organization site name
|
||||
* @returns Array of published courses with lesson counts
|
||||
*/
|
||||
export const getPublishedCoursesBySiteName = async (siteName: string, courseIds?: string[]): Promise<TBaseCourse[]> => {
|
||||
export const getPublishedCoursesBySiteName = async (
|
||||
siteName: string,
|
||||
courseIds?: string[],
|
||||
limit?: number
|
||||
): Promise<TBaseCourse[]> => {
|
||||
try {
|
||||
if (courseIds && courseIds.length === 0) {
|
||||
return [];
|
||||
@@ -65,22 +69,46 @@ export const getPublishedCoursesBySiteName = async (siteName: string, courseIds?
|
||||
conditions.push(inArray(schema.course.id, courseIds));
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
course: schema.course,
|
||||
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count')
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
|
||||
.where(and(...conditions))
|
||||
.groupBy(schema.course.id)
|
||||
.orderBy(desc(schema.course.createdAt));
|
||||
const exerciseCountSql = sql<number>`(
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM ${schema.exercise} as ex
|
||||
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
|
||||
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
|
||||
)`.as('exercise_count');
|
||||
|
||||
const result = limit
|
||||
? await db
|
||||
.select({
|
||||
course: schema.course,
|
||||
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count'),
|
||||
exerciseCount: exerciseCountSql
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
|
||||
.where(and(...conditions))
|
||||
.groupBy(schema.course.id)
|
||||
.orderBy(desc(schema.course.createdAt))
|
||||
.limit(limit)
|
||||
: await db
|
||||
.select({
|
||||
course: schema.course,
|
||||
lessonCount: sql<number>`COUNT(${schema.lesson.id})`.as('lesson_count'),
|
||||
exerciseCount: exerciseCountSql
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
|
||||
.where(and(...conditions))
|
||||
.groupBy(schema.course.id)
|
||||
.orderBy(desc(schema.course.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.course,
|
||||
lessonCount: Number(row.lessonCount)
|
||||
lessonCount: Number(row.lessonCount),
|
||||
exerciseCount: Number(row.exerciseCount)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getCoursesBySiteName error:', error);
|
||||
@@ -90,6 +118,40 @@ export const getPublishedCoursesBySiteName = async (siteName: string, courseIds?
|
||||
}
|
||||
};
|
||||
|
||||
export const countPublishedCoursesBySiteName = async (siteName: string, courseIds?: string[]): Promise<number> => {
|
||||
try {
|
||||
if (courseIds && courseIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(schema.organization.siteName, siteName),
|
||||
eq(schema.course.status, 'ACTIVE'),
|
||||
eq(schema.course.isPublished, true)
|
||||
];
|
||||
|
||||
if (courseIds && courseIds.length > 0) {
|
||||
conditions.push(inArray(schema.course.id, courseIds));
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
.select({
|
||||
total: count(schema.course.id)
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
|
||||
.where(and(...conditions));
|
||||
|
||||
return Number(result?.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error('countPublishedCoursesBySiteName error:', error);
|
||||
throw new Error(
|
||||
`Failed to count courses by site name "${siteName}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets courses by organization orgId
|
||||
* @param orgId Organization orgId
|
||||
@@ -354,18 +416,18 @@ export async function getCourseWithRelations(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCourse(newCourse: TNewCourse) {
|
||||
export async function createCourse(newCourse: TNewCourse, dbClient: DbOrTxClient = db) {
|
||||
try {
|
||||
return await db.insert(schema.course).values(newCourse).returning();
|
||||
return await dbClient.insert(schema.course).values(newCourse).returning();
|
||||
} catch (error) {
|
||||
console.error('createCourse error:', error);
|
||||
throw new Error(`Failed to create course: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCourseNewsfeed(newsfeed: TNewCourseNewsfeed) {
|
||||
export async function createCourseNewsfeed(newsfeed: TNewCourseNewsfeed, dbClient: DbOrTxClient = db) {
|
||||
try {
|
||||
return await db.insert(schema.courseNewsfeed).values(newsfeed).returning();
|
||||
return await dbClient.insert(schema.courseNewsfeed).values(newsfeed).returning();
|
||||
} catch (error) {
|
||||
console.error('createCourseNewsfeed error:', error);
|
||||
throw new Error(`Failed to create course newsfeed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
@@ -392,6 +454,18 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumps updatedAt on a course without changing any other fields.
|
||||
* Call this whenever course content (lessons, exercises) changes.
|
||||
*/
|
||||
export async function touchCourseUpdatedAt(courseId: string) {
|
||||
try {
|
||||
await db.update(schema.course).set({ updatedAt: new Date().toISOString() }).where(eq(schema.course.id, courseId));
|
||||
} catch (error) {
|
||||
console.error('touchCourseUpdatedAt error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft deletes a course by setting status to 'DELETED'
|
||||
* @param courseId Course ID
|
||||
@@ -563,6 +637,20 @@ interface GetOrgCoursesOptions {
|
||||
profileId?: string;
|
||||
/** Optional course IDs filter */
|
||||
courseIds?: string[];
|
||||
/** Optional search query against the course title */
|
||||
search?: string;
|
||||
/** Page number (1-indexed) */
|
||||
page?: number;
|
||||
/** Page size */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GetOrgCoursesResult {
|
||||
items: TAdminCourse[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,47 +660,101 @@ interface GetOrgCoursesOptions {
|
||||
* @param options.profileId Optional profile ID to filter by membership
|
||||
* @returns Array of courses with admin-level data
|
||||
*/
|
||||
export const getOrgCourses = async ({ orgId, profileId, courseIds }: GetOrgCoursesOptions): Promise<TAdminCourse[]> => {
|
||||
export const getOrgCourses = async ({
|
||||
orgId,
|
||||
profileId,
|
||||
courseIds,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 20
|
||||
}: GetOrgCoursesOptions): Promise<GetOrgCoursesResult> => {
|
||||
try {
|
||||
if (courseIds && courseIds.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0
|
||||
};
|
||||
}
|
||||
|
||||
const conditions = [eq(schema.group.organizationId, orgId), eq(schema.course.status, 'ACTIVE')];
|
||||
|
||||
if (courseIds && courseIds.length > 0) {
|
||||
conditions.push(inArray(schema.course.id, courseIds));
|
||||
}
|
||||
|
||||
if (search?.trim()) {
|
||||
conditions.push(ilike(schema.course.title, `%${search.trim()}%`));
|
||||
}
|
||||
|
||||
const totalQuery = profileId
|
||||
? db
|
||||
.select({ count: count(schema.course.id) })
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(
|
||||
schema.groupmember,
|
||||
and(eq(schema.groupmember.groupId, schema.group.id), eq(schema.groupmember.profileId, profileId))
|
||||
)
|
||||
.where(and(...conditions))
|
||||
: db
|
||||
.select({ count: count(schema.course.id) })
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.where(and(...conditions));
|
||||
|
||||
const [countRow] = await totalQuery;
|
||||
const total = Number(countRow?.count ?? 0);
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
course: schema.course,
|
||||
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('lesson_count'),
|
||||
studentCount: sql<number>`(SELECT COUNT(*)::bigint
|
||||
FROM ${schema.groupmember} as gm
|
||||
WHERE gm.group_id = ${schema.course.groupId}
|
||||
WHERE ${eq(sql`gm.group_id`, schema.course.groupId)}
|
||||
AND gm.role_id = ${ROLE.STUDENT}
|
||||
)`.as('total_students')
|
||||
)`.as('total_students'),
|
||||
exerciseCount: sql<number>`(
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM ${schema.exercise} as ex
|
||||
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
|
||||
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
|
||||
)`.as('exercise_count')
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId));
|
||||
|
||||
// Build conditions
|
||||
const conditions = [eq(schema.group.organizationId, orgId), eq(schema.course.status, 'ACTIVE')];
|
||||
|
||||
if (courseIds && courseIds.length > 0) {
|
||||
conditions.push(inArray(schema.course.id, courseIds));
|
||||
}
|
||||
|
||||
// If profileId provided, join groupmember and filter by membership
|
||||
const query = profileId
|
||||
const itemsQuery = profileId
|
||||
? baseQuery
|
||||
.innerJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
|
||||
.where(and(...conditions, eq(schema.groupmember.profileId, profileId)))
|
||||
.innerJoin(
|
||||
schema.groupmember,
|
||||
and(eq(schema.groupmember.groupId, schema.group.id), eq(schema.groupmember.profileId, profileId))
|
||||
)
|
||||
.where(and(...conditions))
|
||||
: baseQuery.where(and(...conditions));
|
||||
|
||||
const result = await query.groupBy(schema.course.id).orderBy(desc(schema.course.createdAt));
|
||||
const result = await itemsQuery
|
||||
.groupBy(schema.course.id)
|
||||
.orderBy(desc(schema.course.createdAt))
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit);
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.course,
|
||||
lessonCount: Number(row.lessonCount),
|
||||
totalStudents: Number(row.studentCount)
|
||||
}));
|
||||
return {
|
||||
items: result.map((row) => ({
|
||||
...row.course,
|
||||
lessonCount: Number(row.lessonCount),
|
||||
totalStudents: Number(row.studentCount),
|
||||
exerciseCount: Number(row.exerciseCount)
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: total === 0 ? 0 : Math.ceil(total / limit)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getOrgCourses error:', error);
|
||||
throw new Error(`Failed to get org courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
@@ -638,6 +780,15 @@ export const getEnrolledCourses = async ({
|
||||
profileId
|
||||
}: GetEnrolledCoursesOptions): Promise<TStudentCourse[]> => {
|
||||
try {
|
||||
// Single query covering both access paths:
|
||||
// 1. Direct enrollment: student has a groupmember row for the course's group
|
||||
// 2. Program membership: student is a program_member of a program that contains the course
|
||||
//
|
||||
// Profile filters are pushed into each LEFT JOIN's ON clause so the database
|
||||
// can filter early. The WHERE then requires at least one path to have matched.
|
||||
// GROUP BY course.id deduplicates courses accessible via both paths simultaneously.
|
||||
// exercisesCompleted uses a profileId-based subquery so it doesn't depend on
|
||||
// the outer groupmember join (which may be NULL for program-only access).
|
||||
const result = await db
|
||||
.select({
|
||||
course: schema.course,
|
||||
@@ -646,7 +797,7 @@ export const getEnrolledCourses = async ({
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM ${schema.lessonCompletion} as lc
|
||||
JOIN ${schema.lesson} as l ON l.id = lc.lesson_id
|
||||
WHERE l.course_id = ${schema.course.id}
|
||||
WHERE ${eq(sql`l.course_id`, schema.course.id)}
|
||||
AND lc.is_complete = true
|
||||
AND lc.profile_id = ${sql.raw(`'${profileId}'::uuid`)}
|
||||
)`.as('progress_rate'),
|
||||
@@ -654,31 +805,49 @@ export const getEnrolledCourses = async ({
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM ${schema.exercise} as ex
|
||||
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
|
||||
WHERE ex.course_id = ${schema.course.id}
|
||||
OR el.course_id = ${schema.course.id}
|
||||
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
|
||||
)`.as('exercise_count'),
|
||||
exercisesCompleted: sql<number>`(
|
||||
SELECT COUNT(DISTINCT s.exercise_id)::bigint
|
||||
FROM ${schema.submission} as s
|
||||
JOIN ${schema.exercise} as ex ON ex.id = s.exercise_id
|
||||
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
|
||||
WHERE (ex.course_id = ${schema.course.id} OR el.course_id = ${schema.course.id})
|
||||
AND s.submitted_by = ${schema.groupmember.id}
|
||||
JOIN ${schema.groupmember} as gm ON gm.id = s.submitted_by
|
||||
WHERE ${and(
|
||||
or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id)),
|
||||
sql`gm.profile_id = ${sql.raw(`'${profileId}'::uuid`)}`
|
||||
)}
|
||||
)`.as('exercises_completed')
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.groupmember, eq(schema.group.id, schema.groupmember.groupId))
|
||||
.leftJoin(
|
||||
schema.groupmember,
|
||||
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
|
||||
)
|
||||
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
|
||||
.leftJoin(
|
||||
schema.program,
|
||||
and(
|
||||
eq(schema.programCourse.programId, schema.program.id),
|
||||
eq(schema.program.organizationId, orgId),
|
||||
eq(schema.program.status, 'ACTIVE')
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
schema.programMember,
|
||||
and(eq(schema.program.id, schema.programMember.programId), eq(schema.programMember.profileId, profileId))
|
||||
)
|
||||
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.group.organizationId, orgId),
|
||||
eq(schema.course.status, 'ACTIVE'),
|
||||
eq(schema.course.isPublished, true),
|
||||
eq(schema.groupmember.profileId, profileId)
|
||||
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id))
|
||||
)
|
||||
)
|
||||
.groupBy(schema.course.id, schema.groupmember.id)
|
||||
.groupBy(schema.course.id)
|
||||
.orderBy(desc(schema.course.createdAt));
|
||||
|
||||
return result.map((row) => ({
|
||||
@@ -711,7 +880,13 @@ export const getExploreCourses = async ({ orgId, profileId }: GetExploreCoursesO
|
||||
const result = await db
|
||||
.select({
|
||||
course: schema.course,
|
||||
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons')
|
||||
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'),
|
||||
exerciseCount: sql<number>`(
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM ${schema.exercise} as ex
|
||||
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
|
||||
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
|
||||
)`.as('exercise_count')
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
@@ -733,7 +908,8 @@ export const getExploreCourses = async ({ orgId, profileId }: GetExploreCoursesO
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.course,
|
||||
lessonCount: Number(row.lessonCount)
|
||||
lessonCount: Number(row.lessonCount),
|
||||
exerciseCount: Number(row.exerciseCount)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getExploreCourses error:', error);
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
eq,
|
||||
group,
|
||||
groupmember,
|
||||
isNotNull,
|
||||
lesson,
|
||||
lessonCompletion,
|
||||
organization,
|
||||
profile,
|
||||
sql
|
||||
} from '@db/drizzle';
|
||||
@@ -24,7 +24,11 @@ export function getCourseStats(orgId: string) {
|
||||
completedLessons: sql<number>`
|
||||
COUNT(DISTINCT ${lessonCompletion.id})
|
||||
FILTER (WHERE ${lessonCompletion.isComplete} = true)
|
||||
`.as('completed_lessons')
|
||||
`.as('completed_lessons'),
|
||||
certificatesEarned: sql<number>`
|
||||
COUNT(DISTINCT ${groupmember.id})
|
||||
FILTER (WHERE ${groupmember.certificateEarnedAt} IS NOT NULL)
|
||||
`.as('certificates_earned')
|
||||
})
|
||||
.from(course)
|
||||
.innerJoin(group, eq(group.id, course.groupId))
|
||||
@@ -49,6 +53,12 @@ export function getCourseStats(orgId: string) {
|
||||
WHEN ${subquery.totalStudents} * ${subquery.totalLessons} = 0 THEN 0
|
||||
ELSE ROUND((${subquery.completedLessons}::numeric / (${subquery.totalStudents} * ${subquery.totalLessons})) * 100)::integer
|
||||
END
|
||||
`,
|
||||
certificationPercentage: sql<number>`
|
||||
CASE
|
||||
WHEN ${subquery.totalStudents} = 0 THEN 0
|
||||
ELSE ROUND((${subquery.certificatesEarned}::numeric / ${subquery.totalStudents}) * 100)::integer
|
||||
END
|
||||
`
|
||||
})
|
||||
.from(subquery)
|
||||
@@ -76,6 +86,52 @@ export function getRecentEnrollments(orgId: string) {
|
||||
.limit(5);
|
||||
}
|
||||
|
||||
/** Count of student enrollments that have earned a certificate (per org, active courses). */
|
||||
export function getTotalCertificatesIssued(orgId: string) {
|
||||
return db
|
||||
.select({
|
||||
count: sql<number>`count(${groupmember.id})::int`
|
||||
})
|
||||
.from(groupmember)
|
||||
.innerJoin(group, eq(groupmember.groupId, group.id))
|
||||
.innerJoin(course, eq(course.groupId, group.id))
|
||||
.where(
|
||||
and(
|
||||
eq(group.organizationId, orgId),
|
||||
eq(course.status, 'ACTIVE'),
|
||||
eq(groupmember.roleId, 3),
|
||||
isNotNull(groupmember.certificateEarnedAt)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Most recent certificate earners for the org dashboard. */
|
||||
export function getRecentCertifications(orgId: string) {
|
||||
return db
|
||||
.select({
|
||||
profileId: profile.id,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
fullname: profile.fullname,
|
||||
courseId: course.id,
|
||||
courseTitle: course.title,
|
||||
earnedAt: groupmember.certificateEarnedAt
|
||||
})
|
||||
.from(groupmember)
|
||||
.innerJoin(group, eq(groupmember.groupId, group.id))
|
||||
.innerJoin(course, eq(course.groupId, group.id))
|
||||
.innerJoin(profile, eq(profile.id, groupmember.profileId))
|
||||
.where(
|
||||
and(
|
||||
eq(group.organizationId, orgId),
|
||||
eq(course.status, 'ACTIVE'),
|
||||
eq(groupmember.roleId, 3),
|
||||
isNotNull(groupmember.certificateEarnedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(groupmember.certificateEarnedAt))
|
||||
.limit(5);
|
||||
}
|
||||
|
||||
export function getDashOrgStats(orgId: string) {
|
||||
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface OrganisationAnalytics {
|
||||
revenue: number;
|
||||
totalCertificates: number;
|
||||
numberOfCourses: number;
|
||||
totalStudents: number;
|
||||
topCourses: {
|
||||
@@ -7,8 +7,9 @@ export interface OrganisationAnalytics {
|
||||
title: string;
|
||||
enrollments: number;
|
||||
completion: number;
|
||||
certification: number;
|
||||
}[];
|
||||
enrollments: {
|
||||
recentCertifications: {
|
||||
id: string;
|
||||
avatarUrl: string;
|
||||
name: string;
|
||||
|
||||
@@ -210,6 +210,9 @@ export async function deleteExercise(exerciseId: string, dbClient: DbOrTxClient
|
||||
export async function createQuestions(values: TNewQuestion[], dbClient: DbOrTxClient = db) {
|
||||
if (values.length === 0) return [];
|
||||
try {
|
||||
// Keep the identity sequence aligned before bulk inserts so restored data
|
||||
// or manual imports cannot reuse an existing question id.
|
||||
await syncQuestionIdSequence(dbClient);
|
||||
return dbClient.insert(schema.question).values(values).returning();
|
||||
} catch (error) {
|
||||
const err = error as Error & { code?: string; cause?: unknown };
|
||||
@@ -268,6 +271,9 @@ export async function deleteQuestion(questionId: number, dbClient: DbOrTxClient
|
||||
export async function createOptions(values: TNewOption[], dbClient: DbOrTxClient = db) {
|
||||
if (values.length === 0) return [];
|
||||
try {
|
||||
// Keep the identity sequence aligned before bulk inserts so restored data
|
||||
// or manual imports cannot reuse an existing option id.
|
||||
await syncOptionIdSequence(dbClient);
|
||||
return dbClient.insert(schema.option).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('createOptions error:', error);
|
||||
@@ -524,3 +530,19 @@ export async function syncOptionIdSequence(dbClient: DbOrTxClient = db): Promise
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the question table's identity sequence to the current max(id).
|
||||
* Call before inserting questions to avoid duplicate key on question_pkey when the sequence is behind.
|
||||
*/
|
||||
export async function syncQuestionIdSequence(dbClient: DbOrTxClient = db): Promise<void> {
|
||||
await dbClient.execute(
|
||||
sql`
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('question', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM question), 1),
|
||||
EXISTS(SELECT 1 FROM question)
|
||||
)
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { TNewGroup, TNewGroupmember } from '@db/types';
|
||||
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
import { and, asc, eq, inArray, isNotNull, or } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } from '@db/drizzle';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
|
||||
export async function createGroup(values: TNewGroup) {
|
||||
export async function createGroup(values: TNewGroup, dbClient: DbOrTxClient = db) {
|
||||
try {
|
||||
return db.insert(schema.group).values(values).returning();
|
||||
return dbClient.insert(schema.group).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('createGroup error:', error);
|
||||
throw new Error(`Failed to create group: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGroupMember(values: TNewGroupmember) {
|
||||
export async function addGroupMember(values: TNewGroupmember, dbClient: DbOrTxClient = db) {
|
||||
try {
|
||||
return db.insert(schema.groupmember).values(values).returning();
|
||||
return dbClient.insert(schema.groupmember).values(values).returning();
|
||||
} catch (error) {
|
||||
console.error('addGroupMember error:', error);
|
||||
throw new Error(`Failed to add group member: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
@@ -223,6 +223,59 @@ export const isCourseTeamMemberOrOrgAdmin = async (courseId: string, profileId:
|
||||
return result.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves program-based access to a course.
|
||||
* Returns the course group/org context plus the matched program member role.
|
||||
*/
|
||||
export async function getCourseProgramAccess(
|
||||
courseId: string,
|
||||
profileId: string
|
||||
): Promise<{
|
||||
courseId: string;
|
||||
courseGroupId: string;
|
||||
organizationId: string | null;
|
||||
profileEmail: string | null;
|
||||
roleId: number;
|
||||
} | null> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
courseId: schema.course.id,
|
||||
courseGroupId: schema.group.id,
|
||||
organizationId: schema.group.organizationId,
|
||||
profileEmail: schema.profile.email,
|
||||
roleId: schema.programMember.roleId
|
||||
})
|
||||
.from(schema.course)
|
||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.innerJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
|
||||
.innerJoin(
|
||||
schema.program,
|
||||
and(eq(schema.program.id, schema.programCourse.programId), eq(schema.program.status, 'ACTIVE'))
|
||||
)
|
||||
.innerJoin(
|
||||
schema.programMember,
|
||||
and(eq(schema.programMember.programId, schema.program.id), eq(schema.programMember.profileId, profileId))
|
||||
)
|
||||
.leftJoin(schema.profile, eq(schema.programMember.profileId, schema.profile.id))
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.orderBy(asc(schema.programMember.roleId))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...result[0],
|
||||
roleId: Number(result[0].roleId)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getCourseProgramAccess error:', error);
|
||||
throw new Error(`Failed to get course program access: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the group member ID for a user in a course
|
||||
* @param courseId Course ID
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from './newsfeed';
|
||||
export * from './submission';
|
||||
export * from './tag';
|
||||
export * from './course-import';
|
||||
export * from './program';
|
||||
|
||||
@@ -44,6 +44,15 @@ export async function updateLessonLanguage(
|
||||
return language;
|
||||
}
|
||||
|
||||
export async function deleteLessonLanguage(lessonId: string, locale: TLocale): Promise<TLessonLanguage | null> {
|
||||
const [language] = await db
|
||||
.delete(schema.lessonLanguage)
|
||||
.where(and(eq(schema.lessonLanguage.lessonId, lessonId), eq(schema.lessonLanguage.locale, locale)))
|
||||
.returning();
|
||||
|
||||
return language || null;
|
||||
}
|
||||
|
||||
export async function upsertLessonLanguage(data: TNewLessonLanguage): Promise<TLessonLanguage> {
|
||||
// Check if exists
|
||||
const existing = await getLessonLanguageByLessonIdAndLocale(data.lessonId!, data.locale || 'en');
|
||||
|
||||
@@ -482,7 +482,7 @@ function getCommentCount() {
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)::int
|
||||
FROM ${schema.courseNewsfeedComment}
|
||||
WHERE ${schema.courseNewsfeedComment.courseNewsfeedId} = ${schema.courseNewsfeed.id}),
|
||||
WHERE ${eq(schema.courseNewsfeedComment.courseNewsfeedId, schema.courseNewsfeed.id)}),
|
||||
0
|
||||
)
|
||||
`.as('commentCount');
|
||||
|
||||
@@ -272,6 +272,59 @@ export async function getLatestOrgInvitesByEmails(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the latest active (non-revoked, non-accepted, non-expired) org invite for a given org+email.
|
||||
* Used to surface a pending invite to a logged-in student on the LMS dashboard.
|
||||
*/
|
||||
export async function getActivePendingOrgInviteForEmail(
|
||||
organizationId: string,
|
||||
email: string
|
||||
): Promise<TOrganizationInviteTokenData | null> {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({
|
||||
invite: schema.organizationInvite,
|
||||
organization: {
|
||||
id: schema.organization.id,
|
||||
name: schema.organization.name,
|
||||
siteName: schema.organization.siteName
|
||||
}
|
||||
})
|
||||
.from(schema.organizationInvite)
|
||||
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationInvite.organizationId, organizationId),
|
||||
eq(schema.organizationInvite.email, normalized),
|
||||
eq(schema.organizationInvite.isRevoked, false),
|
||||
isNull(schema.organizationInvite.acceptedAt),
|
||||
gt(schema.organizationInvite.expiresAt, sql`NOW()`)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.organizationInvite.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getActivePendingOrgInviteForEmail error:', error);
|
||||
throw new Error(
|
||||
`Failed to get active pending org invite: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest organization_invite row for an org + email (by created_at), for metadata when resending.
|
||||
*/
|
||||
|
||||
@@ -268,6 +268,67 @@ export const deleteOrganizationAudienceMember = async (orgId: string, memberId:
|
||||
return deleted || null;
|
||||
};
|
||||
|
||||
export const getOrganizationAudienceMember = async (orgId: string, memberId: number) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
memberId: schema.organizationmember.id,
|
||||
profileId: schema.organizationmember.profileId,
|
||||
fullname: schema.profile.fullname,
|
||||
email: sql<string>`coalesce(${schema.profile.email}, ${schema.organizationmember.email})`.as('email'),
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
profileCreatedAt: schema.profile.createdAt,
|
||||
memberCreatedAt: schema.organizationmember.createdAt
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
eq(schema.organizationmember.id, memberId),
|
||||
eq(schema.organizationmember.roleId, ROLE.STUDENT)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const email = row.email?.trim() ?? '';
|
||||
const name = row.fullname?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
|
||||
const createdAtRaw = row.profileId ? row.profileCreatedAt : row.memberCreatedAt;
|
||||
const createdAt = createdAtRaw ? new Date(createdAtRaw).toDateString() : '';
|
||||
|
||||
return {
|
||||
id: row.memberId,
|
||||
profileId: row.profileId ?? null,
|
||||
name,
|
||||
email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
createdAt
|
||||
};
|
||||
};
|
||||
|
||||
export const updateOrganizationAudienceMember = async (
|
||||
orgId: string,
|
||||
memberId: number,
|
||||
data: Partial<Pick<TNewOrganizationmember, 'email' | 'verified'>>
|
||||
) => {
|
||||
const [updated] = await db
|
||||
.update(schema.organizationmember)
|
||||
.set(data)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
eq(schema.organizationmember.id, memberId),
|
||||
eq(schema.organizationmember.roleId, ROLE.STUDENT)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a user is an admin of an organization
|
||||
* @param orgId Organization ID
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './program';
|
||||
@@ -0,0 +1,699 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { db } 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 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): Promise<TProgramMember> {
|
||||
try {
|
||||
const [member] = await db.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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
+160
-1
@@ -701,7 +701,7 @@ export const lesson = pgTable(
|
||||
isUnlocked: boolean('is_unlocked').default(false),
|
||||
videos: jsonb().default([]).$type<
|
||||
{
|
||||
type: 'youtube' | 'generic' | 'upload';
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
link: string;
|
||||
key?: string;
|
||||
assetId?: string;
|
||||
@@ -1569,6 +1569,11 @@ export const questionAnswer = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Exercise question kinds (`typename` matches `@cio/question-types` keys). Expected ids:
|
||||
* 1 RADIO, 2 CHECKBOX, 3 TEXTAREA, 4 TRUE_FALSE, 5 SHORT_ANSWER, 6 NUMERIC, 7 FILL_BLANK,
|
||||
* 8 FILE_UPLOAD, 9 MATCHING, 10 ORDERING, 11 HOTSPOT, 12 LINK, 13 WORD_BANK, 14 STAR.
|
||||
*/
|
||||
export const questionType = pgTable(
|
||||
'question_type',
|
||||
{
|
||||
@@ -2114,3 +2119,157 @@ export const courseImportDraft = pgTable(
|
||||
.where(sql`${table.idempotencyKey} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
|
||||
// ─── Programs ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const programStatus = pgEnum('PROGRAM_STATUS', ['ACTIVE', 'INACTIVE', 'ARCHIVED']);
|
||||
|
||||
export const program = pgTable(
|
||||
'program',
|
||||
{
|
||||
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: programStatus().default('ACTIVE').notNull(),
|
||||
createdByProfileId: uuid('created_by_profile_id')
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organization.id],
|
||||
name: 'program_organization_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.createdByProfileId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'program_created_by_profile_id_fkey'
|
||||
}),
|
||||
index('idx_program_organization_id').on(table.organizationId)
|
||||
]
|
||||
);
|
||||
|
||||
export const programCourse = pgTable(
|
||||
'program_course',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
programId: uuid('program_id').notNull(),
|
||||
courseId: uuid('course_id').notNull(),
|
||||
addedAt: timestamp('added_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.programId],
|
||||
foreignColumns: [program.id],
|
||||
name: 'program_course_program_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.courseId],
|
||||
foreignColumns: [course.id],
|
||||
name: 'program_course_course_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
unique('program_course_program_id_course_id_unique').on(table.programId, table.courseId),
|
||||
index('idx_program_course_program_id').on(table.programId)
|
||||
]
|
||||
);
|
||||
|
||||
export const programMember = pgTable(
|
||||
'program_member',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
programId: uuid('program_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.programId],
|
||||
foreignColumns: [program.id],
|
||||
name: 'program_member_program_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.profileId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'program_member_profile_id_fkey'
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.roleId],
|
||||
foreignColumns: [role.id],
|
||||
name: 'program_member_role_id_fkey'
|
||||
}),
|
||||
unique('program_member_program_id_profile_id_unique').on(table.programId, table.profileId),
|
||||
index('idx_program_member_program_id').on(table.programId),
|
||||
index('idx_program_member_profile_id').on(table.profileId)
|
||||
]
|
||||
);
|
||||
|
||||
export const programNewsfeed = pgTable(
|
||||
'program_newsfeed',
|
||||
{
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
authorId: uuid('author_id'),
|
||||
programId: uuid('program_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: [programMember.id],
|
||||
name: 'program_newsfeed_author_id_fkey'
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.programId],
|
||||
foreignColumns: [program.id],
|
||||
name: 'program_newsfeed_program_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
index('idx_program_newsfeed_program_id').on(table.programId)
|
||||
]
|
||||
);
|
||||
|
||||
export const programNewsfeedComment = pgTable(
|
||||
'program_newsfeed_comment',
|
||||
{
|
||||
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
|
||||
name: 'program_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(),
|
||||
programNewsfeedId: uuid('program_newsfeed_id')
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.authorId],
|
||||
foreignColumns: [programMember.id],
|
||||
name: 'program_newsfeed_comment_author_id_fkey'
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.programNewsfeedId],
|
||||
foreignColumns: [programNewsfeed.id],
|
||||
name: 'program_newsfeed_comment_program_newsfeed_id_fkey'
|
||||
}).onDelete('cascade')
|
||||
]
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ type LessonDocument = {
|
||||
};
|
||||
|
||||
type AssetKind = 'video' | 'document' | 'image' | 'audio' | 'other';
|
||||
type AssetProvider = 'upload' | 'youtube' | 'generic' | 'external_url';
|
||||
type AssetProvider = 'upload' | 'youtube' | 'generic' | 'external_url' | 'google_drive';
|
||||
type SlotType = 'lesson_video' | 'lesson_document';
|
||||
|
||||
type MediaSummaryKey =
|
||||
|
||||
@@ -64,6 +64,18 @@ export async function seedQuestionTypes() {
|
||||
typename: 'LINK',
|
||||
createdAt: '2021-08-07 18:49:46.246529+00',
|
||||
updatedAt: '2021-08-15 00:57:38.634665+00'
|
||||
},
|
||||
{
|
||||
label: 'Word bank',
|
||||
typename: 'WORD_BANK',
|
||||
createdAt: '2021-08-07 18:49:46.246529+00',
|
||||
updatedAt: '2021-08-15 00:57:38.634665+00'
|
||||
},
|
||||
{
|
||||
label: 'Star rating',
|
||||
typename: 'STAR',
|
||||
createdAt: '2021-08-07 18:49:46.246529+00',
|
||||
updatedAt: '2021-08-15 00:57:38.634665+00'
|
||||
}
|
||||
].filter((q) => !questionTypeNames.includes(q.typename));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user