chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
export * from './exercise';
|
||||
export * from './exercise';
|
||||
export * from './exercise-section';
|
||||
export * from './lms';
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from './organization';
|
||||
export * from './query-errors';
|
||||
export * from './auth';
|
||||
export * from './attendance';
|
||||
export * from './audience';
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user