import { EventRouter, buildUpdateAccountUpdate } from "@/modules/eventRouter"; import { db } from "@/storage/db"; import { Fastify } from "../types"; import { getPublicUrl } from "@/storage/files"; import { z } from "zod"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { allocateUserSeq } from "@/storage/seq"; import { log } from "@/utils/log"; export function registerAccountRoutes(app: Fastify, eventRouter: EventRouter) { app.get('/v1/account/profile', { preHandler: app.authenticate, }, async (request, reply) => { const userId = request.userId; const user = await db.account.findUniqueOrThrow({ where: { id: userId }, select: { firstName: true, lastName: true, avatar: true, githubUser: true } }); return reply.send({ id: userId, timestamp: Date.now(), firstName: user.firstName, lastName: user.lastName, avatar: user.avatar ? { ...user.avatar, url: getPublicUrl(user.avatar.path) } : null, github: user.githubUser ? user.githubUser.profile : null }); }); // Get Account Settings API app.get('/v1/account/settings', { preHandler: app.authenticate, schema: { response: { 200: z.object({ settings: z.string().nullable(), settingsVersion: z.number() }), 500: z.object({ error: z.literal('Failed to get account settings') }) } } }, async (request, reply) => { try { const user = await db.account.findUnique({ where: { id: request.userId }, select: { settings: true, settingsVersion: true } }); if (!user) { return reply.code(500).send({ error: 'Failed to get account settings' }); } return reply.send({ settings: user.settings, settingsVersion: user.settingsVersion }); } catch (error) { return reply.code(500).send({ error: 'Failed to get account settings' }); } }); // Update Account Settings API app.post('/v1/account/settings', { schema: { body: z.object({ settings: z.string().nullable(), expectedVersion: z.number().int().min(0) }), response: { 200: z.union([z.object({ success: z.literal(true), version: z.number() }), z.object({ success: z.literal(false), error: z.literal('version-mismatch'), currentVersion: z.number(), currentSettings: z.string().nullable() })]), 500: z.object({ success: z.literal(false), error: z.literal('Failed to update account settings') }) } }, preHandler: app.authenticate }, async (request, reply) => { const userId = request.userId; const { settings, expectedVersion } = request.body; try { // Get current user data for version check const currentUser = await db.account.findUnique({ where: { id: userId }, select: { settings: true, settingsVersion: true } }); if (!currentUser) { return reply.code(500).send({ success: false, error: 'Failed to update account settings' }); } // Check current version if (currentUser.settingsVersion !== expectedVersion) { return reply.code(200).send({ success: false, error: 'version-mismatch', currentVersion: currentUser.settingsVersion, currentSettings: currentUser.settings }); } // Update settings with version check const { count } = await db.account.updateMany({ where: { id: userId, settingsVersion: expectedVersion }, data: { settings: settings, settingsVersion: expectedVersion + 1, updatedAt: new Date() } }); if (count === 0) { // Re-fetch to get current version const account = await db.account.findUnique({ where: { id: userId } }); return reply.code(200).send({ success: false, error: 'version-mismatch', currentVersion: account?.settingsVersion || 0, currentSettings: account?.settings || null }); } // Generate update for connected clients const updSeq = await allocateUserSeq(userId); const settingsUpdate = { value: settings, version: expectedVersion + 1 }; // Send account update to all user connections const updatePayload = buildUpdateAccountUpdate(userId, { settings: settingsUpdate }, updSeq, randomKeyNaked(12)); eventRouter.emitUpdate({ userId, payload: updatePayload, recipientFilter: { type: 'all-user-authenticated-connections' } }); return reply.send({ success: true, version: expectedVersion + 1 }); } catch (error) { log({ module: 'api', level: 'error' }, `Failed to update account settings: ${error}`); return reply.code(500).send({ success: false, error: 'Failed to update account settings' }); } }); app.post('/v1/usage/query', { schema: { body: z.object({ sessionId: z.string().nullish(), startTime: z.number().int().positive().nullish(), endTime: z.number().int().positive().nullish(), groupBy: z.enum(['hour', 'day']).nullish() }) }, preHandler: app.authenticate }, async (request, reply) => { const userId = request.userId; const { sessionId, startTime, endTime, groupBy } = request.body; const actualGroupBy = groupBy || 'day'; try { // Build query conditions const where: { accountId: string; sessionId?: string | null; createdAt?: { gte?: Date; lte?: Date; }; } = { accountId: userId }; if (sessionId) { // Verify session belongs to user const session = await db.session.findFirst({ where: { id: sessionId, accountId: userId } }); if (!session) { return reply.code(404).send({ error: 'Session not found' }); } where.sessionId = sessionId; } if (startTime || endTime) { where.createdAt = {}; if (startTime) { where.createdAt.gte = new Date(startTime * 1000); } if (endTime) { where.createdAt.lte = new Date(endTime * 1000); } } // Fetch usage reports const reports = await db.usageReport.findMany({ where, orderBy: { createdAt: 'desc' } }); // Aggregate data by time period const aggregated = new Map; cost: Record; count: number; timestamp: number; }>(); for (const report of reports) { const data = report.data as PrismaJson.UsageReportData; const date = new Date(report.createdAt); // Calculate timestamp based on groupBy let timestamp: number; if (actualGroupBy === 'hour') { // Round down to hour const hourDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0, 0); timestamp = Math.floor(hourDate.getTime() / 1000); } else { // Round down to day const dayDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); timestamp = Math.floor(dayDate.getTime() / 1000); } const key = timestamp.toString(); if (!aggregated.has(key)) { aggregated.set(key, { tokens: {}, cost: {}, count: 0, timestamp }); } const agg = aggregated.get(key)!; agg.count++; // Aggregate tokens for (const [tokenKey, tokenValue] of Object.entries(data.tokens)) { if (typeof tokenValue === 'number') { agg.tokens[tokenKey] = (agg.tokens[tokenKey] || 0) + tokenValue; } } // Aggregate costs for (const [costKey, costValue] of Object.entries(data.cost)) { if (typeof costValue === 'number') { agg.cost[costKey] = (agg.cost[costKey] || 0) + costValue; } } } // Convert to array and sort by timestamp const result = Array.from(aggregated.values()) .map(data => ({ timestamp: data.timestamp, tokens: data.tokens, cost: data.cost, reportCount: data.count })) .sort((a, b) => a.timestamp - b.timestamp); return reply.send({ usage: result, groupBy: actualGroupBy, totalReports: reports.length }); } catch (error) { log({ module: 'api', level: 'error' }, `Failed to query usage reports: ${error}`); return reply.code(500).send({ error: 'Failed to query usage reports' }); } }); }