chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -53,12 +53,12 @@ export async function createChatDocument(record: {
}
}
export async function getChatDocument(documentId: string): Promise<ChatDocumentRecord | null> {
export async function getChatDocument(documentId: string, userId: string): Promise<ChatDocumentRecord | null> {
try {
const [row] = await db
.select()
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.id, documentId))
.where(and(eq(schema.aiChatDocument.id, documentId), eq(schema.aiChatDocument.userId, userId)))
.limit(1);
return row ?? null;
@@ -0,0 +1,54 @@
import * as schema from '@db/schema';
import { eq } from 'drizzle-orm';
import { db } from '@db/drizzle';
export type ResourceCourseBinding = {
courseId: string | null;
title: string | null;
};
export async function getLessonCourseBinding(lessonId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.lesson.courseId, title: schema.lesson.title })
.from(schema.lesson)
.where(eq(schema.lesson.id, lessonId))
.limit(1);
return row;
} catch (error) {
console.error('getLessonCourseBinding error:', error);
throw new Error(`Failed to load lesson: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExerciseCourseBinding(exerciseId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.exercise.courseId, title: schema.exercise.title })
.from(schema.exercise)
.where(eq(schema.exercise.id, exerciseId))
.limit(1);
return row;
} catch (error) {
console.error('getExerciseCourseBinding error:', error);
throw new Error(`Failed to load exercise: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getCourseSectionBinding(sectionId: string): Promise<ResourceCourseBinding | undefined> {
try {
const [row] = await db
.select({ courseId: schema.courseSection.courseId, title: schema.courseSection.title })
.from(schema.courseSection)
.where(eq(schema.courseSection.id, sectionId))
.limit(1);
return row;
} catch (error) {
console.error('getCourseSectionBinding error:', error);
throw new Error(`Failed to load section: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -0,0 +1,136 @@
import { desc, eq, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export interface CreditPurchaseInput {
orgId: string;
triggeredBy?: string | null;
provider?: 'polar';
providerOrderId: string;
tokens: number;
quantity: number;
unitPriceCents: number;
currency: string;
payload?: Record<string, unknown>;
}
export async function findCreditPurchaseByOrderId(providerOrderId: string) {
try {
const [row] = await db
.select()
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.providerOrderId, providerOrderId))
.limit(1);
return row ?? null;
} catch (error) {
console.error('findCreditPurchaseByOrderId error:', error);
throw new Error('Failed to find credit purchase');
}
}
export async function createCreditPurchase(input: CreditPurchaseInput) {
try {
const [row] = await db
.insert(schema.aiCreditPurchase)
.values({
orgId: input.orgId,
triggeredBy: input.triggeredBy ?? null,
provider: input.provider ?? 'polar',
providerOrderId: input.providerOrderId,
tokens: input.tokens,
quantity: input.quantity,
unitPriceCents: input.unitPriceCents,
currency: input.currency,
payload: input.payload ?? null
})
.returning();
return row;
} catch (error) {
console.error('createCreditPurchase error:', error);
throw new Error('Failed to create credit purchase');
}
}
export async function summarizePurchases(orgId: string) {
try {
const [row] = await db
.select({
totalPurchasedTokens: sql<number>`COALESCE(SUM(${schema.aiCreditPurchase.tokens}), 0)`,
totalSpentCents: sql<number>`COALESCE(SUM(${schema.aiCreditPurchase.unitPriceCents} * ${schema.aiCreditPurchase.quantity}), 0)`,
lastPurchaseAt: sql<string | null>`MAX(${schema.aiCreditPurchase.createdAt})`
})
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.orgId, orgId));
return {
totalPurchasedTokens: Number(row?.totalPurchasedTokens ?? 0),
totalSpentCents: Number(row?.totalSpentCents ?? 0),
lastPurchaseAt: row?.lastPurchaseAt ?? null
};
} catch (error) {
console.error('summarizePurchases error:', error);
throw new Error('Failed to summarize purchases');
}
}
export async function listCreditPurchases(orgId: string, limit = 50) {
try {
return db
.select()
.from(schema.aiCreditPurchase)
.where(eq(schema.aiCreditPurchase.orgId, orgId))
.orderBy(desc(schema.aiCreditPurchase.createdAt))
.limit(limit);
} catch (error) {
console.error('listCreditPurchases error:', error);
throw new Error('Failed to list credit purchases');
}
}
/** Inserts purchase row and applies balance in one transaction. Caller checks idempotency first. */
export async function insertCreditPurchaseAndApplyBalance(input: CreditPurchaseInput) {
try {
return await db.transaction(async (tx) => {
const [purchase] = await tx
.insert(schema.aiCreditPurchase)
.values({
orgId: input.orgId,
triggeredBy: input.triggeredBy ?? null,
provider: input.provider ?? 'polar',
providerOrderId: input.providerOrderId,
tokens: input.tokens,
quantity: input.quantity,
unitPriceCents: input.unitPriceCents,
currency: input.currency,
payload: input.payload ?? null
})
.returning();
const [existingBalance] = await tx
.select({ id: schema.aiCreditBalance.id })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, input.orgId))
.limit(1);
if (existingBalance) {
await tx
.update(schema.aiCreditBalance)
.set({
balance: sql`${schema.aiCreditBalance.balance} + ${input.tokens}`,
updatedAt: new Date().toISOString()
})
.where(eq(schema.aiCreditBalance.orgId, input.orgId));
} else {
await tx.insert(schema.aiCreditBalance).values({ orgId: input.orgId, balance: input.tokens });
}
return purchase;
});
} catch (error) {
console.error('insertCreditPurchaseAndApplyBalance error:', error);
throw new Error('Failed to record credit purchase');
}
}
+4
View File
@@ -1,2 +1,6 @@
export * from './chat-history';
export * from './chat-document';
export * from './chat-resource';
export * from './credit-purchase';
export * from './token-usage';
export * from './usage-aggregations';
@@ -0,0 +1,144 @@
import { and, eq, gte, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export async function getMonthlyTokenUsage(orgId: string, since: Date): Promise<number> {
try {
const [row] = await db
.select({
total: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())));
return Number(row?.total ?? 0);
} catch (error) {
console.error('getMonthlyTokenUsage error:', error);
throw new Error('Failed to get monthly token usage');
}
}
export async function getDailyTokenUsageHistory(
orgId: string,
since: Date
): Promise<{ date: string; tokens: number }[]> {
try {
const rows = await db
.select({
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
tokens: sql<number>`SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}))`
})
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())))
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
.orderBy(sql`DATE(${schema.aiTokenUsage.createdAt})`);
return rows.map((row) => ({ date: String(row.date), tokens: Number(row.tokens) }));
} catch (error) {
console.error('getDailyTokenUsageHistory error:', error);
throw new Error('Failed to get daily token usage history');
}
}
export async function getOrgCreditBalance(orgId: string): Promise<number> {
try {
const [row] = await db
.select({ balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
return row?.balance ?? 0;
} catch (error) {
console.error('getOrgCreditBalance error:', error);
throw new Error('Failed to get org credit balance');
}
}
export interface InsertTokenUsageParams {
orgId: string;
userId: string;
courseId: string;
promptTokens: number;
completionTokens: number;
costUnits: number;
model: string;
planAllowance: number;
since: Date;
}
export async function insertTokenUsageAndDrainCredits(params: InsertTokenUsageParams): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.insert(schema.aiTokenUsage).values({
orgId: params.orgId,
userId: params.userId,
courseId: params.courseId,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
costUnits: params.costUnits,
model: params.model
});
const [usageRow] = await tx
.select({
total: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(
and(
eq(schema.aiTokenUsage.orgId, params.orgId),
gte(schema.aiTokenUsage.createdAt, params.since.toISOString())
)
);
const monthlyUsageAfter = Number(usageRow?.total ?? 0);
const monthlyUsageBefore = monthlyUsageAfter - params.costUnits;
const allowanceRemainingBefore = Math.max(0, params.planAllowance - monthlyUsageBefore);
const overflow = Math.max(0, params.costUnits - allowanceRemainingBefore);
if (overflow <= 0) {
return;
}
await tx
.update(schema.aiCreditBalance)
.set({
balance: sql`GREATEST(0, ${schema.aiCreditBalance.balance} - ${overflow})`,
updatedAt: new Date().toISOString()
})
.where(eq(schema.aiCreditBalance.orgId, params.orgId));
});
} catch (error) {
console.error('insertTokenUsageAndDrainCredits error:', error);
throw new Error('Failed to record token usage');
}
}
export async function upsertCreditBalance(orgId: string, amount: number): Promise<number> {
try {
const existing = await db
.select({ id: schema.aiCreditBalance.id, balance: schema.aiCreditBalance.balance })
.from(schema.aiCreditBalance)
.where(eq(schema.aiCreditBalance.orgId, orgId))
.limit(1);
if (existing.length > 0) {
const newBalance = existing[0].balance + amount;
await db
.update(schema.aiCreditBalance)
.set({ balance: newBalance, updatedAt: new Date().toISOString() })
.where(eq(schema.aiCreditBalance.orgId, orgId));
return newBalance;
}
await db.insert(schema.aiCreditBalance).values({ orgId, balance: amount });
return amount;
} catch (error) {
console.error('upsertCreditBalance error:', error);
throw new Error('Failed to upsert credit balance');
}
}
@@ -0,0 +1,68 @@
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
export interface UsageLeaderboardRow {
userId: string;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
tokens: number;
favoriteModel: string | null;
requests: number;
}
/**
* Aggregate ai_token_usage by user for an org since a given date.
* Joins to profile for display fields. Returns rows sorted by tokens DESC.
*/
export async function aggregateTokenUsageByUser(orgId: string, since: Date): Promise<UsageLeaderboardRow[]> {
try {
const rows = await db
.select({
userId: schema.aiTokenUsage.userId,
fullname: schema.profile.fullname,
email: schema.profile.email,
avatarUrl: schema.profile.avatarUrl,
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`,
favoriteModel: sql<string | null>`MODE() WITHIN GROUP (ORDER BY ${schema.aiTokenUsage.model})`,
requests: sql<number>`COUNT(*)`
})
.from(schema.aiTokenUsage)
.leftJoin(schema.profile, eq(schema.profile.id, schema.aiTokenUsage.userId))
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())))
.groupBy(schema.aiTokenUsage.userId, schema.profile.fullname, schema.profile.email, schema.profile.avatarUrl)
.orderBy(
desc(
sql`SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}))`
)
);
return rows.map((row) => ({
...row,
tokens: Number(row.tokens),
requests: Number(row.requests)
}));
} catch (error) {
console.error('aggregateTokenUsageByUser error:', error);
throw new Error('Failed to aggregate token usage by user');
}
}
/**
* Total request count for an org since a given date — used for the chart header.
*/
export async function countRequests(orgId: string, since: Date): Promise<number> {
try {
const [row] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(schema.aiTokenUsage)
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, since.toISOString())));
return Number(row?.count ?? 0);
} catch (error) {
console.error('countRequests error:', error);
throw new Error('Failed to count requests');
}
}