chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS "ai_chat_document" (
"id" text PRIMARY KEY NOT NULL,
"conversation_id" uuid NOT NULL,
"course_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"asset_id" uuid,
"file_name" text NOT NULL,
"mime_type" text NOT NULL,
"text" text NOT NULL,
"word_count" integer DEFAULT 0 NOT NULL,
"page_count" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ai_chat_document" ADD CONSTRAINT "ai_chat_document_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "ai_chat_conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_ai_chat_document_conversation" ON "ai_chat_document" ("conversation_id");
@@ -53,12 +53,12 @@ export async function createChatDocument(record: {
}
}
export async function getChatDocument(documentId: string): Promise<ChatDocumentRecord | null> {
export async function getChatDocument(documentId: string, userId: string): Promise<ChatDocumentRecord | null> {
try {
const [row] = await db
.select()
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.id, documentId))
.where(and(eq(schema.aiChatDocument.id, documentId), eq(schema.aiChatDocument.userId, userId)))
.limit(1);
return row ?? null;
@@ -0,0 +1,54 @@
import * as schema from '@db/schema';
import { eq } from 'drizzle-orm';
import { db } from '@db/drizzle';
export type ResourceCourseBinding = {
courseId: string | null;
title: string | null;
};
export async function getLessonCourseBinding(lessonId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.lesson.courseId, title: schema.lesson.title })
.from(schema.lesson)
.where(eq(schema.lesson.id, lessonId))
.limit(1);
return row;
} catch (error) {
console.error('getLessonCourseBinding error:', error);
throw new Error(`Failed to load lesson: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExerciseCourseBinding(exerciseId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.exercise.courseId, title: schema.exercise.title })
.from(schema.exercise)
.where(eq(schema.exercise.id, exerciseId))
.limit(1);
return row;
} catch (error) {
console.error('getExerciseCourseBinding error:', error);
throw new Error(`Failed to load exercise: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCourseSectionBinding(sectionId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.courseSection.courseId, title: schema.courseSection.title })
.from(schema.courseSection)
.where(eq(schema.courseSection.id, sectionId))
.limit(1);
return row;
} catch (error) {
console.error('getCourseSectionBinding error:', error);
throw new Error(`Failed to load section: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -0,0 +1,136 @@
import { desc, eq, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export interface CreditPurchaseInput {
orgId: string;
triggeredBy?: string | null;
provider?: 'polar';
providerOrderId: string;
tokens: number;
quantity: number;
unitPriceCents: number;
currency: string;
payload?: Record<string, unknown>;
}
export async function findCreditPurchaseByOrderId(providerOrderId: string) {
try {
const [row] = await db
.select()
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.providerOrderId, providerOrderId))
.limit(1);
return row ?? null;
} catch (error) {
console.error('findCreditPurchaseByOrderId error:', error);
throw new Error('Failed to find credit purchase');
}
}
export async function createCreditPurchase(input: CreditPurchaseInput) {
try {
const [row] = await db
.insert(schema.aiCreditPurchase)
.values({
orgId: input.orgId,
triggeredBy: input.triggeredBy ?? null,
provider: input.provider ?? 'polar',
providerOrderId: input.providerOrderId,
tokens: input.tokens,
quantity: input.quantity,
unitPriceCents: input.unitPriceCents,
currency: input.currency,
payload: input.payload ?? null
})
.returning();
return row;
} catch (error) {
console.error('createCreditPurchase error:', error);
throw new Error('Failed to create credit purchase');
}
}
export async function summarizePurchases(orgId: string) {
try {
const [row] = await db
.select({
totalPurchasedTokens: sql<number>`COALESCE(SUM(${schema.aiCreditPurchase.tokens}), 0)`,
totalSpentCents: sql<number>`COALESCE(SUM(${schema.aiCreditPurchase.unitPriceCents} * ${schema.aiCreditPurchase.quantity}), 0)`,
lastPurchaseAt: sql<string | null>`MAX(${schema.aiCreditPurchase.createdAt})`
})
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.orgId, orgId));
return {
totalPurchasedTokens: Number(row?.totalPurchasedTokens ?? 0),
totalSpentCents: Number(row?.totalSpentCents ?? 0),
lastPurchaseAt: row?.lastPurchaseAt ?? null
};
} catch (error) {
console.error('summarizePurchases error:', error);
throw new Error('Failed to summarize purchases');
}
}
export async function listCreditPurchases(orgId: string, limit = 50) {
try {
return db
.select()
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.orgId, orgId))
.orderBy(desc(schema.aiCreditPurchase.createdAt))
.limit(limit);
} catch (error) {
console.error('listCreditPurchases error:', error);
throw new Error('Failed to list credit purchases');
}
}
/** Inserts purchase row and applies balance in one transaction. Caller checks idempotency first. */
export async function insertCreditPurchaseAndApplyBalance(input: CreditPurchaseInput) {
try {
return await db.transaction(async (tx) => {
const [purchase] = await tx
.insert(schema.aiCreditPurchase)
.values({
orgId: input.orgId,
triggeredBy: input.triggeredBy ?? null,
provider: input.provider ?? 'polar',
providerOrderId: input.providerOrderId,
tokens: input.tokens,
quantity: input.quantity,
unitPriceCents: input.unitPriceCents,
currency: input.currency,
payload: input.payload ?? null
})
.returning();
const [existingBalance] = await tx
.select({ id: schema.aiCreditBalance.id })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, input.orgId))
.limit(1);
if (existingBalance) {
await tx
.update(schema.aiCreditBalance)
.set({
balance: sql`${schema.aiCreditBalance.balance} + ${input.tokens}`,
updatedAt: new Date().toISOString()
})
.where(eq(schema.aiCreditBalance.orgId, input.orgId));
} else {
await tx.insert(schema.aiCreditBalance).values({ orgId: input.orgId, balance: input.tokens });
}
return purchase;
});
} catch (error) {
console.error('insertCreditPurchaseAndApplyBalance error:', error);
throw new Error('Failed to record credit purchase');
}
}
+4
View File
@@ -1,2 +1,6 @@
export * from './chat-history';
export * from './chat-document';
export * from './chat-resource';
export * from './credit-purchase';
export * from './token-usage';
export * from './usage-aggregations';
@@ -0,0 +1,144 @@
import { and, eq, gte, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export async function getMonthlyTokenUsage(orgId: string, since: Date): Promise<number> {
try {
const [row] = await db
.select({
total: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())));
return Number(row?.total ?? 0);
} catch (error) {
console.error('getMonthlyTokenUsage error:', error);
throw new Error('Failed to get monthly token usage');
}
}
export async function getDailyTokenUsageHistory(
orgId: string,
since: Date
): Promise<{ date: string; tokens: number }[]> {
try {
const rows = await db
.select({
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
tokens: sql<number>`SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}))`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())))
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
.orderBy(sql`DATE(${schema.aiTokenUsage.createdAt})`);
return rows.map((row) => ({ date: String(row.date), tokens: Number(row.tokens) }));
} catch (error) {
console.error('getDailyTokenUsageHistory error:', error);
throw new Error('Failed to get daily token usage history');
}
}
export async function getOrgCreditBalance(orgId: string): Promise<number> {
try {
const [row] = await db
.select({ balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
return row?.balance ?? 0;
} catch (error) {
console.error('getOrgCreditBalance error:', error);
throw new Error('Failed to get org credit balance');
}
}
export interface InsertTokenUsageParams {
orgId: string;
userId: string;
courseId: string;
promptTokens: number;
completionTokens: number;
costUnits: number;
model: string;
planAllowance: number;
since: Date;
}
export async function insertTokenUsageAndDrainCredits(params: InsertTokenUsageParams): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.insert(schema.aiTokenUsage).values({
orgId: params.orgId,
userId: params.userId,
courseId: params.courseId,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
costUnits: params.costUnits,
model: params.model
});
const [usageRow] = await tx
.select({
total: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(
and(
eq(schema.aiTokenUsage.orgId, params.orgId),
gte(schema.aiTokenUsage.createdAt, params.since.toISOString())
)
);
const monthlyUsageAfter = Number(usageRow?.total ?? 0);
const monthlyUsageBefore = monthlyUsageAfter - params.costUnits;
const allowanceRemainingBefore = Math.max(0, params.planAllowance - monthlyUsageBefore);
const overflow = Math.max(0, params.costUnits - allowanceRemainingBefore);
if (overflow <= 0) {
return;
}
await tx
.update(schema.aiCreditBalance)
.set({
balance: sql`GREATEST(0, ${schema.aiCreditBalance.balance} - ${overflow})`,
updatedAt: new Date().toISOString()
})
.where(eq(schema.aiCreditBalance.orgId, params.orgId));
});
} catch (error) {
console.error('insertTokenUsageAndDrainCredits error:', error);
throw new Error('Failed to record token usage');
}
}
export async function upsertCreditBalance(orgId: string, amount: number): Promise<number> {
try {
const existing = await db
.select({ id: schema.aiCreditBalance.id, balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
if (existing.length > 0) {
const newBalance = existing[0].balance + amount;
await db
.update(schema.aiCreditBalance)
.set({ balance: newBalance, updatedAt: new Date().toISOString() })
.where(eq(schema.aiCreditBalance.orgId, orgId));
return newBalance;
}
await db.insert(schema.aiCreditBalance).values({ orgId, balance: amount });
return amount;
} catch (error) {
console.error('upsertCreditBalance error:', error);
throw new Error('Failed to upsert credit balance');
}
}
@@ -0,0 +1,68 @@
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export interface UsageLeaderboardRow {
userId: string;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
tokens: number;
favoriteModel: string | null;
requests: number;
}
/**
* Aggregate ai_token_usage by user for an org since a given date.
* Joins to profile for display fields. Returns rows sorted by tokens DESC.
*/
export async function aggregateTokenUsageByUser(orgId: string, since: Date): Promise<UsageLeaderboardRow[]> {
try {
const rows = await db
.select({
userId: schema.aiTokenUsage.userId,
fullname: schema.profile.fullname,
email: schema.profile.email,
avatarUrl: schema.profile.avatarUrl,
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`,
favoriteModel: sql<string | null>`MODE() WITHIN GROUP (ORDER BY ${schema.aiTokenUsage.model})`,
requests: sql<number>`COUNT(*)`
})
.from(schema.aiTokenUsage)
.leftJoin(schema.profile, eq(schema.profile.id, schema.aiTokenUsage.userId))
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())))
.groupBy(schema.aiTokenUsage.userId, schema.profile.fullname, schema.profile.email, schema.profile.avatarUrl)
.orderBy(
desc(
sql`SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}))`
)
);
return rows.map((row) => ({
...row,
tokens: Number(row.tokens),
requests: Number(row.requests)
}));
} catch (error) {
console.error('aggregateTokenUsageByUser error:', error);
throw new Error('Failed to aggregate token usage by user');
}
}
/**
* Total request count for an org since a given date — used for the chart header.
*/
export async function countRequests(orgId: string, since: Date): Promise<number> {
try {
const [row] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())));
return Number(row?.count ?? 0);
} catch (error) {
console.error('countRequests error:', error);
throw new Error('Failed to count requests');
}
}
@@ -0,0 +1,186 @@
import type { TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
import { ContentType, ErrorCodes } from '@cio/utils/constants';
import * as schema from '@db/schema';
import { inArray, sql } from 'drizzle-orm';
import type { DbOrTxClient } from '@db/drizzle';
import { OperationalQueryError } from '../query-errors';
const { courseSection, lesson, exercise } = schema;
function buildUpdatePayload(item: TCourseContentUpdate['items'][number]) {
const payload: Record<string, unknown> = {};
if (item.isUnlocked !== undefined) {
payload.isUnlocked = item.isUnlocked;
}
if (item.order !== undefined) {
payload.order = item.order;
}
if (item.sectionId !== undefined) {
payload.sectionId = item.sectionId;
}
return payload;
}
export async function applyCourseContentBulkUpdates(
tx: DbOrTxClient,
items: TCourseContentUpdate['items']
): Promise<{ updatedItems: number; updatedLessons: number; updatedExercises: number }> {
const updates = items
.map((item) => ({
item,
payload: buildUpdatePayload(item)
}))
.filter(({ payload }) => Object.keys(payload).length > 0);
if (updates.length === 0) {
return {
updatedItems: 0,
updatedLessons: 0,
updatedExercises: 0
};
}
const updatedAt = new Date().toISOString();
const lessonUpdates = updates.filter(({ item }) => item.type === ContentType.Lesson);
const exerciseUpdates = updates.filter(({ item }) => item.type === ContentType.Exercise);
if (lessonUpdates.length > 0) {
const lessonIds = lessonUpdates.map(({ item }) => item.id);
const lessonOrderUpdates = lessonUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const lessonSectionUpdates = lessonUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const lessonUnlockUpdates = lessonUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const lessonSet: Record<string, unknown> = {
updatedAt
};
if (lessonOrderUpdates.length > 0) {
lessonSet.order = sql`case ${lesson.id} ${sql.join(
lessonOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.order} end`;
}
if (lessonSectionUpdates.length > 0) {
lessonSet.sectionId = sql`case ${lesson.id} ${sql.join(
lessonSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.sectionId} end`;
}
if (lessonUnlockUpdates.length > 0) {
lessonSet.isUnlocked = sql`case ${lesson.id} ${sql.join(
lessonUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${lesson.isUnlocked} end`;
}
const updatedLessons = await tx
.update(lesson)
.set(lessonSet)
.where(inArray(lesson.id, lessonIds))
.returning({ id: lesson.id });
if (updatedLessons.length !== lessonIds.length) {
throw new OperationalQueryError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
}
}
if (exerciseUpdates.length > 0) {
const exerciseIds = exerciseUpdates.map(({ item }) => item.id);
const exerciseOrderUpdates = exerciseUpdates
.filter(({ payload }) => payload.order !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.order }));
const exerciseSectionUpdates = exerciseUpdates
.filter(({ payload }) => payload.sectionId !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.sectionId }));
const exerciseUnlockUpdates = exerciseUpdates
.filter(({ payload }) => payload.isUnlocked !== undefined)
.map(({ item, payload }) => ({ id: item.id, value: payload.isUnlocked }));
const exerciseSet: Record<string, unknown> = {
updatedAt
};
if (exerciseOrderUpdates.length > 0) {
exerciseSet.order = sql`case ${exercise.id} ${sql.join(
exerciseOrderUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.order} end`;
}
if (exerciseSectionUpdates.length > 0) {
exerciseSet.sectionId = sql`case ${exercise.id} ${sql.join(
exerciseSectionUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.sectionId} end`;
}
if (exerciseUnlockUpdates.length > 0) {
exerciseSet.isUnlocked = sql`case ${exercise.id} ${sql.join(
exerciseUnlockUpdates.map((update) => sql`when ${update.id} then ${update.value}`),
sql` `
)} else ${exercise.isUnlocked} end`;
}
const updatedExercises = await tx
.update(exercise)
.set(exerciseSet)
.where(inArray(exercise.id, exerciseIds))
.returning({ id: exercise.id });
if (updatedExercises.length !== exerciseIds.length) {
throw new OperationalQueryError('Exercise not found', ErrorCodes.EXERCISE_NOT_FOUND, 404);
}
}
return {
updatedItems: updates.length,
updatedLessons: lessonUpdates.length,
updatedExercises: exerciseUpdates.length
};
}
export async function applyCourseSectionOrderUpdates(
tx: DbOrTxClient,
sections: NonNullable<TCourseContentReorder['sections']>
): Promise<number> {
if (sections.length === 0) {
return 0;
}
const sectionIds = sections.map((section) => section.id);
const updatedAt = new Date().toISOString();
const sectionSet: Record<string, unknown> = {
updatedAt,
order: sql`case ${courseSection.id} ${sql.join(
sections.map((section) => sql`when ${section.id} then ${section.order}`),
sql` `
)} else ${courseSection.order} end`
};
const updatedSections = await tx
.update(courseSection)
.set(sectionSet)
.where(inArray(courseSection.id, sectionIds))
.returning({ id: courseSection.id });
if (updatedSections.length !== sectionIds.length) {
throw new OperationalQueryError('Course section not found', ErrorCodes.COURSE_SECTION_NOT_FOUND, 404);
}
return sections.length;
}
+48
View File
@@ -1287,3 +1287,51 @@ export async function getOrgCourseGroups(orgId: string, courseIds: string[]) {
throw new Error(`Failed to get org course groups: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** True when any course row exists with this slug. */
export async function isCourseSlugTaken(slug: string): Promise<boolean> {
try {
const rows = await db
.select({ id: schema.course.id })
.from(schema.course)
.where(eq(schema.course.slug, slug))
.limit(1);
return rows.length > 0;
} catch (error) {
console.error('isCourseSlugTaken error:', error);
throw new Error(`Failed to check course slug: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** Course `type` only; `undefined` when the course does not exist. */
export async function getCourseTypeById(courseId: string): Promise<string | undefined> {
try {
const [row] = await db
.select({ type: schema.course.type })
.from(schema.course)
.where(eq(schema.course.id, courseId))
.limit(1);
return row?.type ?? undefined;
} catch (error) {
console.error('getCourseTypeById error:', error);
throw new Error(`Failed to get course type: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/** Updates slug + updatedAt; returns the new slug row or null if the course was missing. */
export async function updateCourseSlug(courseId: string, slug: string): Promise<{ slug: string | null } | null> {
try {
const [updated] = await db
.update(schema.course)
.set({ slug, updatedAt: new Date().toISOString() })
.where(eq(schema.course.id, courseId))
.returning({ slug: schema.course.slug });
return updated ?? null;
} catch (error) {
console.error('updateCourseSlug error:', error);
throw new Error(`Failed to update course slug: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
export * from './course';
export * from './course';
export * from './compliance';
export * from './content';
export * from './content-batch';
export * from './people';
export * from './invite';
export * from './public-course';
+97 -1
View File
@@ -2,7 +2,7 @@ import type { TCourseInvite, TCourseInviteAudit, TNewCourseInvite, TNewCourseInv
import * as schema from '@db/schema';
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
export async function createCourseInvite(values: TNewCourseInvite): Promise<TCourseInvite> {
try {
@@ -333,3 +333,99 @@ export async function getCourseInviteByTokenHash(tokenHash: string): Promise<TCo
);
}
}
export type TCourseInviteAcceptBundle = {
invite: TCourseInvite;
course: {
id: string;
title: string;
status: string;
isPublished: boolean;
metadata: Record<string, unknown> | null;
groupId: string;
};
organization: {
id: string;
name: string;
siteName: string;
};
};
export async function selectCourseInviteAcceptBundleByTokenHash(
dbClient: DbOrTxClient,
tokenHash: string
): Promise<TCourseInviteAcceptBundle | null> {
try {
const [row] = await dbClient
.select({
invite: schema.courseInvite,
course: {
id: schema.course.id,
title: schema.course.title,
status: schema.course.status,
isPublished: schema.course.isPublished,
metadata: schema.course.metadata,
groupId: schema.group.id
},
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.courseInvite)
.innerJoin(schema.course, eq(schema.courseInvite.courseId, schema.course.id))
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.courseInvite.tokenHash, tokenHash))
.limit(1);
if (!row) {
return null;
}
return {
...row,
course: {
...row.course,
isPublished: !!row.course.isPublished
},
organization: {
...row.organization,
siteName: row.organization.siteName ?? ''
}
};
} catch (error) {
console.error('selectCourseInviteAcceptBundleByTokenHash error:', error);
throw new Error(
`Failed to load course invite for accept: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function optimisticIncrementCourseInviteUsedCount(
dbClient: DbOrTxClient,
inviteId: string,
priorUsedCount: number
): Promise<{ id: string } | undefined> {
try {
const [consumeInvite] = await dbClient
.update(schema.courseInvite)
.set({
usedCount: priorUsedCount + 1,
updatedAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString()
})
.where(and(eq(schema.courseInvite.id, inviteId), eq(schema.courseInvite.usedCount, priorUsedCount)))
.returning({
id: schema.courseInvite.id
});
return consumeInvite;
} catch (error) {
console.error('optimisticIncrementCourseInviteUsedCount error:', error);
throw new Error(
`Failed to update course invite usage: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -0,0 +1,534 @@
import { and, asc, eq } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
/**
* Queries backing the anonymous public-course surface under
* `(org-site)/course/[courseSlug]`. Every query in this file assumes the
* course is already confirmed to be `type = 'PUBLIC'` and `is_published = true`
* at the service layer; they do not re-check auth.
*/
export interface PublicCourseHeader {
id: string;
slug: string;
title: string;
description: string | null;
bannerImage: string | null;
callout: {
title: string;
description: string;
buttonLabel: string;
buttonUrl: string;
} | null;
org: {
id: string;
name: string;
siteName: string | null;
avatarUrl: string | null;
} | null;
}
export interface PublicCourseSection {
id: string;
title: string;
order: number;
}
export interface PublicCourseLessonItem {
kind: 'lesson';
id: string;
slug: string;
title: string;
order: number;
sectionId: string | null;
isUnlocked: boolean;
hasVideo: boolean;
}
export interface PublicCourseExerciseItem {
kind: 'exercise';
id: string;
slug: string;
title: string;
order: number;
sectionId: string | null;
isUnlocked: boolean;
}
export type PublicCourseItem = PublicCourseLessonItem | PublicCourseExerciseItem;
export interface PublicCourseTree {
course: PublicCourseHeader;
sections: PublicCourseSection[];
items: PublicCourseItem[];
}
/**
* Minimal course row for slug-based guards (e.g. rate limiting). Does not enforce PUBLIC/published.
*/
export async function getCourseRowBySlug(courseSlug: string): Promise<{
id: string;
type: string | null;
isPublished: boolean | null;
status: string | null;
} | null> {
try {
const [row] = await db
.select({
id: schema.course.id,
type: schema.course.type,
isPublished: schema.course.isPublished,
status: schema.course.status
})
.from(schema.course)
.where(eq(schema.course.slug, courseSlug))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getCourseRowBySlug error:', error);
throw new Error(`Failed to resolve course by slug: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Fetch the course header + its sections + every lesson / exercise (metadata only,
* no bodies) for a public course identified by its URL slug.
*
* Returns `null` when the course does not exist, is not published, or its type
* is not `PUBLIC`.
*/
export async function getPublicCourseTreeBySlug(courseSlug: string): Promise<PublicCourseTree | null> {
try {
const [courseRow] = await db
.select({
id: schema.course.id,
slug: schema.course.slug,
title: schema.course.title,
description: schema.course.description,
bannerImage: schema.course.bannerImage,
callout: schema.course.callout,
type: schema.course.type,
isPublished: schema.course.isPublished,
status: schema.course.status,
groupId: schema.course.groupId
})
.from(schema.course)
.where(eq(schema.course.slug, courseSlug))
.limit(1);
if (!courseRow) return null;
if (courseRow.type !== 'PUBLIC') return null;
if (!courseRow.isPublished) return null;
if (courseRow.status !== 'ACTIVE') return null;
if (!courseRow.slug) return null;
// Resolve the owning organization (course → group → organization). The
// public course page shows org name + avatar in the sticky top header so
// a guest can recognize the creator.
let org: PublicCourseHeader['org'] = null;
if (courseRow.groupId) {
const [orgRow] = await db
.select({
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
avatarUrl: schema.organization.avatarUrl
})
.from(schema.group)
.innerJoin(schema.organization, eq(schema.organization.id, schema.group.organizationId))
.where(eq(schema.group.id, courseRow.groupId))
.limit(1);
if (orgRow) {
org = {
id: orgRow.id,
name: orgRow.name,
siteName: orgRow.siteName ?? null,
avatarUrl: orgRow.avatarUrl ?? null
};
}
}
const sectionRows = await db
.select({
id: schema.courseSection.id,
title: schema.courseSection.title,
order: schema.courseSection.order
})
.from(schema.courseSection)
.where(eq(schema.courseSection.courseId, courseRow.id))
.orderBy(asc(schema.courseSection.order));
const lessonRows = await db
.select({
id: schema.lesson.id,
slug: schema.lesson.slug,
title: schema.lesson.title,
order: schema.lesson.order,
sectionId: schema.lesson.sectionId,
isUnlocked: schema.lesson.isUnlocked,
videos: schema.lesson.videos,
videoUrl: schema.lesson.videoUrl
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseRow.id))
.orderBy(asc(schema.lesson.order));
const exerciseRows = await db
.select({
id: schema.exercise.id,
slug: schema.exercise.slug,
title: schema.exercise.title,
order: schema.exercise.order,
sectionId: schema.exercise.sectionId,
lessonId: schema.exercise.lessonId,
isUnlocked: schema.exercise.isUnlocked
})
.from(schema.exercise)
.where(eq(schema.exercise.courseId, courseRow.id))
.orderBy(asc(schema.exercise.order));
const sections: PublicCourseSection[] = sectionRows
.filter((section): section is { id: string; title: string; order: number } => section.order !== null)
.map((section) => ({
id: section.id,
title: section.title,
order: Number(section.order)
}));
const lessonItems: PublicCourseLessonItem[] = lessonRows
.filter((row): row is typeof row & { slug: string } => typeof row.slug === 'string')
.map((row) => ({
kind: 'lesson',
id: row.id,
slug: row.slug,
title: row.title,
order: row.order === null ? 0 : Number(row.order),
sectionId: row.sectionId,
isUnlocked: row.isUnlocked ?? false,
hasVideo:
(Array.isArray(row.videos) && row.videos.length > 0) ||
(typeof row.videoUrl === 'string' && row.videoUrl.length > 0)
}));
const exerciseItems: PublicCourseExerciseItem[] = exerciseRows
.filter((row): row is typeof row & { slug: string } => typeof row.slug === 'string')
.map((row) => ({
kind: 'exercise',
id: row.id,
slug: row.slug,
title: row.title,
order: row.order === null ? 0 : Number(row.order),
sectionId: row.sectionId,
isUnlocked: row.isUnlocked ?? true
}));
// Sort items so that the visual reading order matches the sidebar:
// items in the first section come before items in the second, and within
// a section items are sorted by their own `order`. Unsectioned items go
// last so the redirect from the course landing page never lands on a
// floating lesson when sections exist.
const UNSECTIONED_ORDER = Number.POSITIVE_INFINITY;
const sectionOrderById = new Map(sections.map((section) => [section.id, section.order]));
const orderForItem = (item: PublicCourseItem) =>
item.sectionId && sectionOrderById.has(item.sectionId)
? (sectionOrderById.get(item.sectionId) ?? UNSECTIONED_ORDER)
: UNSECTIONED_ORDER;
const items = [...lessonItems, ...exerciseItems].sort((left, right) => {
const sectionDiff = orderForItem(left) - orderForItem(right);
if (sectionDiff !== 0) return sectionDiff;
return left.order - right.order;
});
const rawCallout = courseRow.callout;
const callout =
rawCallout && typeof rawCallout === 'object' && 'title' in rawCallout
? {
title: String((rawCallout as { title: unknown }).title ?? ''),
description: String((rawCallout as { description: unknown }).description ?? ''),
buttonLabel: String((rawCallout as { buttonLabel: unknown }).buttonLabel ?? ''),
buttonUrl: String((rawCallout as { buttonUrl: unknown }).buttonUrl ?? '')
}
: null;
return {
course: {
id: courseRow.id,
slug: courseRow.slug,
title: courseRow.title,
description: courseRow.description,
bannerImage: courseRow.bannerImage,
callout,
org
},
sections,
items
};
} catch (error) {
console.error('getPublicCourseTreeBySlug error:', error);
throw new Error(
`Failed to get public course tree for slug "${courseSlug}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export interface PublicLessonContent {
kind: 'lesson';
id: string;
slug: string;
title: string;
sectionTitle: string | null;
isUnlocked: boolean;
body: string;
video: {
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
link: string;
} | null;
}
export interface PublicExerciseContent {
kind: 'exercise';
id: string;
slug: string;
title: string;
description: string | null;
sectionTitle: string | null;
isUnlocked: boolean;
allowMultipleAttempts: boolean;
sectionDisplayMode: string;
}
export type PublicCourseItemContent = PublicLessonContent | PublicExerciseContent;
/**
* Fetch a single lesson or exercise (by per-course unique slug) inside a public course.
* Returns `null` when the course is not public/published, or the item doesn't exist.
*
* The exercise payload returned here intentionally excludes the question list;
* callers should pair this with `getExerciseQuestions` (which already ships with
* answer keys) to render the client-side-graded exercise UI.
*/
export async function getPublicCourseItem(
courseSlug: string,
itemSlug: string
): Promise<PublicCourseItemContent | null> {
try {
const [courseRow] = await db
.select({
id: schema.course.id,
type: schema.course.type,
isPublished: schema.course.isPublished,
status: schema.course.status
})
.from(schema.course)
.where(eq(schema.course.slug, courseSlug))
.limit(1);
if (!courseRow) return null;
if (courseRow.type !== 'PUBLIC') return null;
if (!courseRow.isPublished) return null;
if (courseRow.status !== 'ACTIVE') return null;
const [lessonRow] = await db
.select({
id: schema.lesson.id,
slug: schema.lesson.slug,
title: schema.lesson.title,
note: schema.lesson.note,
videoUrl: schema.lesson.videoUrl,
videos: schema.lesson.videos,
isUnlocked: schema.lesson.isUnlocked,
sectionId: schema.lesson.sectionId
})
.from(schema.lesson)
.where(and(eq(schema.lesson.courseId, courseRow.id), eq(schema.lesson.slug, itemSlug)))
.limit(1);
if (lessonRow) {
const section = lessonRow.sectionId
? await db
.select({ title: schema.courseSection.title })
.from(schema.courseSection)
.where(eq(schema.courseSection.id, lessonRow.sectionId))
.limit(1)
: [];
// Lesson body lives in `lesson_language` (one row per locale). Prefer the
// English row when present; otherwise fall back to the first row, then to
// the legacy `lesson.note` column for very old lessons that predate i18n.
const languageRows = await db
.select({
content: schema.lessonLanguage.content,
locale: schema.lessonLanguage.locale
})
.from(schema.lessonLanguage)
.where(eq(schema.lessonLanguage.lessonId, lessonRow.id));
const englishRow = languageRows.find((row) => row.locale === 'en');
const fallbackRow = languageRows.find((row) => typeof row.content === 'string' && row.content.length > 0);
const body = englishRow?.content ?? fallbackRow?.content ?? lessonRow.note ?? '';
const firstVideo =
Array.isArray(lessonRow.videos) && lessonRow.videos.length > 0
? {
type: lessonRow.videos[0].type,
link: lessonRow.videos[0].link
}
: lessonRow.videoUrl
? { type: 'generic' as const, link: lessonRow.videoUrl }
: null;
return {
kind: 'lesson',
id: lessonRow.id,
slug: lessonRow.slug ?? itemSlug,
title: lessonRow.title,
sectionTitle: section[0]?.title ?? null,
isUnlocked: lessonRow.isUnlocked ?? false,
body,
video: firstVideo
};
}
const [exerciseRow] = await db
.select({
id: schema.exercise.id,
slug: schema.exercise.slug,
title: schema.exercise.title,
description: schema.exercise.description,
isUnlocked: schema.exercise.isUnlocked,
sectionId: schema.exercise.sectionId,
lessonId: schema.exercise.lessonId,
allowMultipleAttempts: schema.exercise.allowMultipleAttempts,
sectionDisplayMode: schema.exercise.sectionDisplayMode
})
.from(schema.exercise)
.where(and(eq(schema.exercise.courseId, courseRow.id), eq(schema.exercise.slug, itemSlug)))
.limit(1);
if (!exerciseRow) return null;
const resolvedSectionId =
exerciseRow.sectionId ??
(exerciseRow.lessonId
? ((
await db
.select({ sectionId: schema.lesson.sectionId })
.from(schema.lesson)
.where(eq(schema.lesson.id, exerciseRow.lessonId))
.limit(1)
)[0]?.sectionId ?? null)
: null);
const section = resolvedSectionId
? await db
.select({ title: schema.courseSection.title })
.from(schema.courseSection)
.where(eq(schema.courseSection.id, resolvedSectionId))
.limit(1)
: [];
return {
kind: 'exercise',
id: exerciseRow.id,
slug: exerciseRow.slug ?? itemSlug,
title: exerciseRow.title,
description: exerciseRow.description,
sectionTitle: section[0]?.title ?? null,
isUnlocked: exerciseRow.isUnlocked ?? true,
allowMultipleAttempts: exerciseRow.allowMultipleAttempts,
sectionDisplayMode: exerciseRow.sectionDisplayMode ?? 'one_question'
};
} catch (error) {
console.error('getPublicCourseItem error:', error);
throw new Error(
`Failed to get public course item "${itemSlug}" for course "${courseSlug}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
/**
* Helper for slug-collision resolution at the service layer. Returns the set of
* slugs already in use for the given course across both lesson and exercise
* tables.
*/
export async function getTakenItemSlugs(courseId: string): Promise<Set<string>> {
try {
const lessonSlugs = await db
.select({ slug: schema.lesson.slug })
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId));
const exerciseSlugs = await db
.select({ slug: schema.exercise.slug })
.from(schema.exercise)
.where(eq(schema.exercise.courseId, courseId));
const taken = new Set<string>();
for (const row of lessonSlugs) {
if (row.slug) taken.add(row.slug);
}
for (const row of exerciseSlugs) {
if (row.slug) taken.add(row.slug);
}
return taken;
} catch (error) {
console.error('getTakenItemSlugs error:', error);
throw new Error(
`Failed to read taken slugs for course "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Count of questions attached to a public course whose type is NOT auto-gradable.
* Used to guard conversion of a course to `PUBLIC`.
*/
export async function findNonAutoGradableQuestionsInCourse(
courseId: string,
autoGradableTypeIds: readonly number[]
): Promise<
Array<{
questionId: number;
questionTitle: string;
exerciseId: string;
exerciseTitle: string;
typeId: number;
}>
> {
try {
const rows = await db
.select({
questionId: schema.question.id,
questionTitle: schema.question.title,
exerciseId: schema.exercise.id,
exerciseTitle: schema.exercise.title,
typeId: schema.question.questionTypeId
})
.from(schema.question)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.question.exerciseId))
.where(eq(schema.exercise.courseId, courseId));
return rows
.filter((row) => row.typeId !== null && !autoGradableTypeIds.includes(row.typeId))
.map((row) => ({
questionId: row.questionId,
questionTitle: row.questionTitle ?? 'Untitled question',
exerciseId: row.exerciseId,
exerciseTitle: row.exerciseTitle,
typeId: row.typeId as number
}));
} catch (error) {
console.error('findNonAutoGradableQuestionsInCourse error:', error);
throw new Error(
`Failed to scan questions for course "${courseId}": ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -0,0 +1,136 @@
import * as schema from '@db/schema';
import { asc, db, eq, inArray } from '@db/drizzle';
import type { DbOrTxClient } from '@db/drizzle';
import type { TNewExerciseSection } from '@db/types';
export async function getExerciseSectionsByExerciseId(exerciseId: string, dbClient: DbOrTxClient = db) {
try {
return dbClient
.select()
.from(schema.exerciseSection)
.where(eq(schema.exerciseSection.exerciseId, exerciseId))
.orderBy(asc(schema.exerciseSection.order));
} catch (error) {
console.error('getExerciseSectionsByExerciseId error:', error);
throw new Error(
`Failed to get exercise sections by exercise ID "${exerciseId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function getExerciseSectionsByExerciseIds(exerciseIds: string[], dbClient: DbOrTxClient = db) {
if (exerciseIds.length === 0) return [];
try {
return dbClient
.select()
.from(schema.exerciseSection)
.where(inArray(schema.exerciseSection.exerciseId, exerciseIds))
.orderBy(asc(schema.exerciseSection.exerciseId), asc(schema.exerciseSection.order));
} catch (error) {
console.error('getExerciseSectionsByExerciseIds error:', error);
throw new Error(`Failed to get exercise sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createExerciseSections(sections: TNewExerciseSection[], dbClient: DbOrTxClient = db) {
if (sections.length === 0) return [];
try {
return dbClient.insert(schema.exerciseSection).values(sections).returning();
} catch (error) {
console.error('createExerciseSections error:', error);
throw new Error(`Failed to create exercise sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateExerciseSection(
exerciseSectionId: string,
data: Partial<Omit<TNewExerciseSection, 'id' | 'exerciseId'>>,
dbClient: DbOrTxClient = db
) {
try {
const [updated] = await dbClient
.update(schema.exerciseSection)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(schema.exerciseSection.id, exerciseSectionId))
.returning();
return updated || null;
} catch (error) {
console.error('updateExerciseSection error:', error);
throw new Error(
`Failed to update exercise section "${exerciseSectionId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function deleteExerciseSection(exerciseSectionId: string, dbClient: DbOrTxClient = db) {
try {
return dbClient.delete(schema.exerciseSection).where(eq(schema.exerciseSection.id, exerciseSectionId)).returning();
} catch (error) {
console.error('deleteExerciseSection error:', error);
throw new Error(
`Failed to delete exercise section "${exerciseSectionId}": ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function deleteExerciseSectionsByIds(exerciseSectionIds: string[], dbClient: DbOrTxClient = db) {
if (exerciseSectionIds.length === 0) return [];
try {
return dbClient
.delete(schema.exerciseSection)
.where(inArray(schema.exerciseSection.id, exerciseSectionIds))
.returning();
} catch (error) {
console.error('deleteExerciseSectionsByIds error:', error);
throw new Error(`Failed to delete exercise sections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function moveQuestionToSection(
questionId: number,
exerciseSectionId: string | null,
dbClient: DbOrTxClient = db
) {
try {
const [updated] = await dbClient
.update(schema.question)
.set({ exerciseSectionId })
.where(eq(schema.question.id, questionId))
.returning();
return updated || null;
} catch (error) {
console.error('moveQuestionToSection error:', error);
throw new Error(`Failed to move question to section: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function bulkMoveQuestionsToSection(
questionIds: number[],
exerciseSectionId: string | null,
dbClient: DbOrTxClient = db
) {
if (questionIds.length === 0) return [];
try {
return dbClient
.update(schema.question)
.set({ exerciseSectionId })
.where(inArray(schema.question.id, questionIds))
.returning();
} catch (error) {
console.error('bulkMoveQuestionsToSection error:', error);
throw new Error(`Failed to move questions to section: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -272,8 +272,6 @@ 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) {
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './exercise';
export * from './exercise';
export * from './exercise-section';
export * from './lms';
+33
View File
@@ -295,3 +295,36 @@ export const getGroupMemberIdByCourseAndProfile = async (
return result.length > 0 ? result[0].id : null;
};
export async function getGroupMemberIdByGroupAndProfile(
groupId: string,
profileId: string,
dbClient: DbOrTxClient = db
): Promise<string | null> {
try {
const [row] = await dbClient
.select({ id: schema.groupmember.id })
.from(schema.groupmember)
.where(and(eq(schema.groupmember.groupId, groupId), eq(schema.groupmember.profileId, profileId)))
.limit(1);
return row?.id ?? null;
} catch (error) {
console.error('getGroupMemberIdByGroupAndProfile error:', error);
throw new Error(`Failed to resolve group membership: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function insertGroupMembersOnConflictDoNothing(
values: TNewGroupmember[],
dbClient: DbOrTxClient = db
): Promise<void> {
if (values.length === 0) return;
try {
await dbClient.insert(schema.groupmember).values(values).onConflictDoNothing();
} catch (error) {
console.error('insertGroupMembersOnConflictDoNothing error:', error);
throw new Error(`Failed to insert group members: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from './organization';
export * from './query-errors';
export * from './auth';
export * from './attendance';
export * from './audience';
+174 -2
View File
@@ -2,12 +2,13 @@ import type {
TNewOrganizationInvite,
TNewOrganizationInviteAudit,
TOrganizationInvite,
TOrganizationInviteAudit
TOrganizationInviteAudit,
TNewOrganizationmember
} from '@db/types';
import * as schema from '@db/schema';
import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
export async function createOrganizationInvite(values: TNewOrganizationInvite): Promise<TOrganizationInvite> {
try {
@@ -358,3 +359,174 @@ export async function getLatestOrganizationInviteRowByOrgAndEmail(
);
}
}
export type TOrganizationInviteAcceptRow = {
invite: TOrganizationInvite;
organization: {
id: string;
name: string;
siteName: string;
};
};
export async function selectOrganizationInviteWithOrgByTokenHash(
dbClient: DbOrTxClient,
tokenHash: string
): Promise<TOrganizationInviteAcceptRow | null> {
try {
const [row] = await dbClient
.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(eq(schema.organizationInvite.tokenHash, tokenHash))
.limit(1);
if (!row) {
return null;
}
return {
...row,
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
};
} catch (error) {
console.error('selectOrganizationInviteWithOrgByTokenHash error:', error);
throw new Error(`Failed to load organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function selectOrganizationInviteWithOrgByInviteId(
dbClient: DbOrTxClient,
inviteId: string
): Promise<TOrganizationInviteAcceptRow | null> {
try {
const [row] = await dbClient
.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(eq(schema.organizationInvite.id, inviteId))
.limit(1);
if (!row) {
return null;
}
return {
...row,
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
};
} catch (error) {
console.error('selectOrganizationInviteWithOrgByInviteId error:', error);
throw new Error(`Failed to load organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function selectOrganizationMemberByOrgAndNormalizedEmail(
dbClient: DbOrTxClient,
organizationId: string,
normalizedEmail: string
) {
try {
const [row] = await dbClient
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, organizationId),
eq(schema.organizationmember.email, normalizedEmail)
)
)
.limit(1);
return row ?? null;
} catch (error) {
console.error('selectOrganizationMemberByOrgAndNormalizedEmail error:', error);
throw new Error(`Failed to load organization member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function selectOrganizationMemberByOrgAndProfile(
dbClient: DbOrTxClient,
organizationId: string,
profileId: string
) {
try {
const [row] = await dbClient
.select()
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, organizationId),
eq(schema.organizationmember.profileId, profileId)
)
)
.limit(1);
return row ?? null;
} catch (error) {
console.error('selectOrganizationMemberByOrgAndProfile error:', error);
throw new Error(`Failed to load organization member: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function updateOrganizationMemberById(
dbClient: DbOrTxClient,
memberId: number,
data: Partial<TNewOrganizationmember>
) {
try {
await dbClient.update(schema.organizationmember).set(data).where(eq(schema.organizationmember.id, memberId));
} catch (error) {
console.error('updateOrganizationMemberById error:', error);
throw new Error(
`Failed to update organization member: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function claimPendingOrganizationInvite(
dbClient: DbOrTxClient,
inviteId: string,
acceptedByProfileId: string
): Promise<{ id: string } | undefined> {
try {
const [acceptedInvite] = await dbClient
.update(schema.organizationInvite)
.set({
acceptedAt: new Date().toISOString(),
acceptedByProfileId,
updatedAt: new Date().toISOString()
})
.where(
and(
eq(schema.organizationInvite.id, inviteId),
eq(schema.organizationInvite.isRevoked, false),
isNull(schema.organizationInvite.acceptedAt)
)
)
.returning({
id: schema.organizationInvite.id
});
return acceptedInvite;
} catch (error) {
console.error('claimPendingOrganizationInvite error:', error);
throw new Error(
`Failed to finalize organization invite: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
@@ -95,6 +95,32 @@ export const createOrganizationMember = async (data: TNewOrganizationmember, dbC
return member;
};
export async function getOrganizationMemberIdByOrgAndProfile(
organizationId: string,
profileId: string,
dbClient: DbOrTxClient = db
): Promise<number | null> {
try {
const [row] = await dbClient
.select({ id: schema.organizationmember.id })
.from(schema.organizationmember)
.where(
and(
eq(schema.organizationmember.organizationId, organizationId),
eq(schema.organizationmember.profileId, profileId)
)
)
.limit(1);
return row?.id ?? null;
} catch (error) {
console.error('getOrganizationMemberIdByOrgAndProfile error:', error);
throw new Error(
`Failed to resolve organization membership: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Creates multiple organization members in a single query
* @param data Array of organization member creation data
@@ -106,6 +132,24 @@ export const createOrganizationMembers = async (data: TNewOrganizationmember[])
return members;
};
export async function insertOrganizationMembersOnConflictDoNothing(
data: TNewOrganizationmember[],
dbClient: DbOrTxClient = db
): Promise<void> {
if (data.length === 0) {
return;
}
try {
await dbClient.insert(schema.organizationmember).values(data).onConflictDoNothing();
} catch (error) {
console.error('insertOrganizationMembersOnConflictDoNothing error:', error);
throw new Error(
`Failed to insert organization members: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export const checkSiteNameExists = async (siteName: string): Promise<boolean> => {
const result = await db
.select({ id: schema.organization.id })
+3 -3
View File
@@ -3,7 +3,7 @@ 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 } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -309,9 +309,9 @@ export async function isOrgAdminByProgramId(programId: string, profileId: string
}
}
export async function addProgramMember(data: TNewProgramMember): Promise<TProgramMember> {
export async function addProgramMember(data: TNewProgramMember, dbClient: DbOrTxClient = db): Promise<TProgramMember> {
try {
const [member] = await db.insert(schema.programMember).values(data).returning();
const [member] = await dbClient.insert(schema.programMember).values(data).returning();
if (!member) throw new Error('Failed to add program member');
return member;
} catch (error) {
+15
View File
@@ -0,0 +1,15 @@
/**
* Thrown from query modules when a domain rule fails and the API layer should
* map this to an AppError with the same code and status.
*/
export class OperationalQueryError extends Error {
constructor(
message: string,
public readonly errorCode: string,
public readonly statusCode: number,
public readonly field?: string
) {
super(message);
this.name = 'OperationalQueryError';
}
}
+92 -10
View File
@@ -23,7 +23,7 @@ import {
import type { AnswerData } from '@cio/question-types';
import { sql } from 'drizzle-orm';
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']);
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE', 'PUBLIC']);
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
@@ -541,7 +541,7 @@ export const submissionstatus = pgTable(
label: varchar().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => []
() => []
);
export const course = pgTable(
@@ -625,7 +625,13 @@ export const course = pgTable(
passingScore?: number;
}>(),
status: text().default('ACTIVE').notNull(),
type: courseType().default('LIVE_CLASS')
type: courseType().default('SELF_PACED'),
callout: jsonb().$type<{
title: string;
description: string;
buttonLabel: string;
buttonUrl: string;
} | null>()
},
(table) => [
foreignKey({
@@ -877,7 +883,8 @@ export const lesson = pgTable(
assetId?: string;
}[]
>(),
sectionId: uuid('section_id')
sectionId: uuid('section_id'),
slug: varchar()
},
(table) => [
foreignKey({
@@ -896,7 +903,8 @@ export const lesson = pgTable(
name: 'public_course_section_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade')
.onDelete('cascade'),
index('idx_lesson_course_slug').on(table.courseId, table.slug)
]
);
@@ -1059,7 +1067,9 @@ export const exercise = pgTable(
.notNull(),
isUnlocked: boolean('is_unlocked').default(true),
dueBy: timestamp('due_by', { mode: 'string' }),
allowMultipleAttempts: boolean('allow_multiple_attempts').default(false).notNull()
allowMultipleAttempts: boolean('allow_multiple_attempts').default(false).notNull(),
sectionDisplayMode: varchar('section_display_mode').default('one_question'),
slug: varchar()
},
(table) => [
foreignKey({
@@ -1076,7 +1086,34 @@ export const exercise = pgTable(
columns: [table.sectionId],
foreignColumns: [courseSection.id],
name: 'exercise_section_id_fkey'
})
}),
index('idx_exercise_course_slug').on(table.courseId, table.slug)
]
);
export const exerciseSection = pgTable(
'exercise_section',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
exerciseId: uuid('exercise_id').notNull(),
title: varchar().notNull().default('Untitled Section'),
description: text(),
order: bigint({ mode: 'number' }).notNull().default(0),
colorTheme: varchar('color_theme').notNull().default('blue'),
afterBehavior: jsonb('after_behavior').notNull().default({ action: 'continue' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => [
foreignKey({
columns: [table.exerciseId],
foreignColumns: [exercise.id],
name: 'exercise_section_exercise_id_fkey'
}).onDelete('cascade'),
index('idx_exercise_section_exercise_id').on(table.exerciseId)
]
);
@@ -1656,6 +1693,7 @@ export const question = pgTable(
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
exerciseId: uuid('exercise_id').notNull(),
exerciseSectionId: uuid('exercise_section_id'),
name: uuid().default(sql`gen_random_uuid()`),
points: doublePrecision(),
settings: jsonb().default({}),
@@ -1668,6 +1706,11 @@ export const question = pgTable(
foreignColumns: [exercise.id],
name: 'question_exercise_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.exerciseSectionId],
foreignColumns: [exerciseSection.id],
name: 'question_exercise_section_id_fkey'
}).onDelete('set null'),
foreignKey({
columns: [table.questionTypeId],
foreignColumns: [questionType.id],
@@ -1721,7 +1764,8 @@ 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.
* 8 FILE_UPLOAD, 9 MATCHING, 10 ORDERING, 11 HOTSPOT, 12 LINK, 13 WORD_BANK, 14 STAR,
* 15 VIDEO_RECORDING.
*/
export const questionType = pgTable(
'question_type',
@@ -1739,7 +1783,7 @@ export const questionType = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
typename: varchar()
},
(table) => []
() => []
);
export const role = pgTable(
@@ -1758,7 +1802,7 @@ export const role = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow()
},
(table) => []
() => []
);
export const waitinglist = pgTable(
@@ -2566,6 +2610,7 @@ export const aiTokenUsage = pgTable(
courseId: uuid('course_id').notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
costUnits: integer('cost_units'),
model: text().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
@@ -2605,6 +2650,43 @@ export const aiCreditBalance = pgTable(
]
);
export const aiCreditPurchase = pgTable(
'ai_credit_purchase',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_credit_purchase_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
triggeredBy: uuid('triggered_by'),
provider: text().notNull().default('polar'),
providerOrderId: text('provider_order_id').notNull(),
tokens: integer().notNull(),
quantity: integer().notNull().default(1),
unitPriceCents: integer('unit_price_cents').notNull().default(0),
currency: text().notNull().default('USD'),
payload: jsonb(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_credit_purchase_org_id_fkey'
}),
foreignKey({
columns: [table.triggeredBy],
foreignColumns: [profile.id],
name: 'ai_credit_purchase_triggered_by_fkey'
}),
unique('ai_credit_purchase_provider_order_id_key').on(table.providerOrderId),
index('idx_ai_credit_purchase_org_created').on(table.orgId, table.createdAt)
]
);
export const aiChatConversation = pgTable(
'ai_chat_conversation',
{
+3
View File
@@ -91,6 +91,9 @@ export type TNewAppsPollSubmission = typeof schema.appsPollSubmission.$inferInse
export type TExercise = typeof schema.exercise.$inferSelect;
export type TNewExercise = typeof schema.exercise.$inferInsert;
export type TExerciseSection = typeof schema.exerciseSection.$inferSelect;
export type TNewExerciseSection = typeof schema.exerciseSection.$inferInsert;
export type TGroupmember = typeof schema.groupmember.$inferSelect;
export type TNewGroupmember = typeof schema.groupmember.$inferInsert;
@@ -76,6 +76,12 @@ export async function seedQuestionTypes() {
typename: 'STAR',
createdAt: '2021-08-07 18:49:46.246529+00',
updatedAt: '2021-08-15 00:57:38.634665+00'
},
{
label: 'Video recording',
typename: 'VIDEO_RECORDING',
createdAt: '2026-04-30 00:00:00+00',
updatedAt: '2026-04-30 00:00:00+00'
}
].filter((q) => !questionTypeNames.includes(q.typename));