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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user