chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user