import { z } from 'zod' import { router, adminProcedure, superAdminProcedure, protectedProcedure } from '../trpc' import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp' import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai' import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage' /** * Categorize an OpenAI model for display */ function categorizeModel(modelId: string): string { const id = modelId.toLowerCase() if (id.startsWith('gpt-4o')) return 'gpt-4o' if (id.startsWith('gpt-4')) return 'gpt-4' if (id.startsWith('gpt-3.5')) return 'gpt-3.5' if (id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4')) return 'reasoning' return 'other' } export const settingsRouter = router({ /** * Get all settings by category */ getByCategory: adminProcedure .input(z.object({ category: z.string() })) .query(async ({ ctx, input }) => { const settings = await ctx.prisma.systemSettings.findMany({ where: { category: input.category as any }, orderBy: { key: 'asc' }, }) // Mask secret values for non-super-admins if (ctx.user.role !== 'SUPER_ADMIN') { return settings.map((s) => ({ ...s, value: s.isSecret ? '********' : s.value, })) } return settings }), /** * Get a single setting */ get: adminProcedure .input(z.object({ key: z.string() })) .query(async ({ ctx, input }) => { const setting = await ctx.prisma.systemSettings.findUnique({ where: { key: input.key }, }) if (!setting) return null // Mask secret values for non-super-admins if (setting.isSecret && ctx.user.role !== 'SUPER_ADMIN') { return { ...setting, value: '********' } } return setting }), /** * Get multiple settings by keys (for client-side caching) */ getMultiple: adminProcedure .input(z.object({ keys: z.array(z.string()) })) .query(async ({ ctx, input }) => { const settings = await ctx.prisma.systemSettings.findMany({ where: { key: { in: input.keys } }, }) // Mask secret values for non-super-admins if (ctx.user.role !== 'SUPER_ADMIN') { return settings.map((s) => ({ ...s, value: s.isSecret ? '********' : s.value, })) } return settings }), /** * Update a setting (super admin only for secrets) */ update: superAdminProcedure .input( z.object({ key: z.string(), value: z.string(), }) ) .mutation(async ({ ctx, input }) => { const setting = await ctx.prisma.systemSettings.update({ where: { key: input.key }, data: { value: input.value, updatedBy: ctx.user.id, }, }) // Audit log (don't log actual value for secrets) await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE_SETTING', entityType: 'SystemSettings', entityId: setting.id, detailsJson: { key: input.key, isSecret: setting.isSecret, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return setting }), /** * Update multiple settings at once (upsert - creates if not exists) */ updateMultiple: superAdminProcedure .input( z.object({ settings: z.array( z.object({ key: z.string(), value: z.string(), category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP']).optional(), }) ), }) ) .mutation(async ({ ctx, input }) => { // Infer category from key prefix if not provided const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => { if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI' if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL' if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE' if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING' if (key.startsWith('whatsapp_')) return 'WHATSAPP' if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY' return 'DEFAULTS' } const results = await Promise.all( input.settings.map((s) => ctx.prisma.systemSettings.upsert({ where: { key: s.key }, update: { value: s.value, updatedBy: ctx.user.id, }, create: { key: s.key, value: s.value, category: s.category || inferCategory(s.key), updatedBy: ctx.user.id, }, }) ) ) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE_SETTINGS_BATCH', entityType: 'SystemSettings', detailsJson: { keys: input.settings.map((s) => s.key) }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return results }), /** * Get all categories */ getCategories: adminProcedure.query(async ({ ctx }) => { const settings = await ctx.prisma.systemSettings.findMany({ select: { category: true }, distinct: ['category'], }) return settings.map((s) => s.category) }), /** * Test AI connection with the configured model */ testAIConnection: superAdminProcedure.mutation(async () => { const result = await testOpenAIConnection() return result }), /** * List available AI models from OpenAI */ listAIModels: superAdminProcedure.query(async () => { const result = await listAvailableModels() if (!result.success || !result.models) { return { success: false, error: result.error || 'Failed to fetch models', models: [], } } // Categorize and annotate models const categorizedModels = result.models.map(model => ({ id: model, name: model, isReasoning: isReasoningModel(model), category: categorizeModel(model), })) // Sort: GPT-4o first, then other GPT-4, then GPT-3.5, then reasoning models const sorted = categorizedModels.sort((a, b) => { const order = ['gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning'] const aOrder = order.findIndex(cat => a.category.startsWith(cat)) const bOrder = order.findIndex(cat => b.category.startsWith(cat)) if (aOrder !== bOrder) return aOrder - bOrder return a.id.localeCompare(b.id) }) return { success: true, models: sorted, } }), /** * Test email connection */ testEmailConnection: superAdminProcedure .input(z.object({ testEmail: z.string().email() })) .mutation(async ({ ctx, input }) => { try { const { sendTestEmail } = await import('@/lib/email') const success = await sendTestEmail(input.testEmail) return { success, error: success ? null : 'Failed to send test email' } } catch (error) { return { success: false, error: 'Email configuration error' } } }), /** * Get WhatsApp settings status */ getWhatsAppStatus: adminProcedure.query(async ({ ctx }) => { const [enabledSetting, providerSetting] = await Promise.all([ ctx.prisma.systemSettings.findUnique({ where: { key: 'whatsapp_enabled' }, }), ctx.prisma.systemSettings.findUnique({ where: { key: 'whatsapp_provider' }, }), ]) return { enabled: enabledSetting?.value === 'true', provider: providerSetting?.value || 'META', } }), /** * Test WhatsApp connection */ testWhatsAppConnection: superAdminProcedure.mutation(async () => { const provider = await getWhatsAppProvider() if (!provider) { return { success: false, error: 'WhatsApp not configured' } } const result = await provider.testConnection() const providerType = await getWhatsAppProviderType() return { ...result, provider: providerType, } }), /** * Send a test WhatsApp message */ sendTestWhatsApp: superAdminProcedure .input(z.object({ phoneNumber: z.string() })) .mutation(async ({ input }) => { const provider = await getWhatsAppProvider() if (!provider) { return { success: false, error: 'WhatsApp not configured' } } return provider.sendText( input.phoneNumber, 'This is a test message from MOPC Platform.' ) }), /** * Update user notification preferences */ updateNotificationPreferences: protectedProcedure .input( z.object({ phoneNumber: z.string().optional().nullable(), notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']), whatsappOptIn: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.prisma.user.update({ where: { id: ctx.user.id }, data: { phoneNumber: input.phoneNumber, notificationPreference: input.notificationPreference, whatsappOptIn: input.whatsappOptIn, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE_NOTIFICATION_PREFERENCES', entityType: 'User', entityId: ctx.user.id, detailsJson: { notificationPreference: input.notificationPreference, whatsappOptIn: input.whatsappOptIn, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return user }), /** * Get notification statistics (admin only) */ getNotificationStats: adminProcedure .input( z.object({ startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), }) ) .query(async ({ ctx, input }) => { const where: Record = {} if (input.startDate || input.endDate) { where.createdAt = {} if (input.startDate) { (where.createdAt as Record).gte = new Date(input.startDate) } if (input.endDate) { (where.createdAt as Record).lte = new Date(input.endDate) } } const [total, byChannel, byStatus, byType] = await Promise.all([ ctx.prisma.notificationLog.count({ where }), ctx.prisma.notificationLog.groupBy({ by: ['channel'], where, _count: true, }), ctx.prisma.notificationLog.groupBy({ by: ['status'], where, _count: true, }), ctx.prisma.notificationLog.groupBy({ by: ['type'], where, _count: true, }), ]) return { total, byChannel: Object.fromEntries( byChannel.map((r) => [r.channel, r._count]) ), byStatus: Object.fromEntries( byStatus.map((r) => [r.status, r._count]) ), byType: Object.fromEntries( byType.map((r) => [r.type, r._count]) ), } }), /** * Get AI usage statistics (admin only) */ getAIUsageStats: adminProcedure .input( z.object({ startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), }) ) .query(async ({ input }) => { const startDate = input.startDate ? new Date(input.startDate) : undefined const endDate = input.endDate ? new Date(input.endDate) : undefined const stats = await getAIUsageStats(startDate, endDate) return { totalTokens: stats.totalTokens, totalCost: stats.totalCost, totalCostFormatted: formatCost(stats.totalCost), byAction: Object.fromEntries( Object.entries(stats.byAction).map(([action, data]) => [ action, { ...data, costFormatted: formatCost(data.cost), }, ]) ), byModel: Object.fromEntries( Object.entries(stats.byModel).map(([model, data]) => [ model, { ...data, costFormatted: formatCost(data.cost), }, ]) ), } }), /** * Get current month AI usage cost (admin only) */ getAICurrentMonthCost: adminProcedure.query(async () => { const { cost, tokens, requestCount } = await getCurrentMonthCost() return { cost, costFormatted: formatCost(cost), tokens, requestCount, } }), /** * Get AI usage history (last 30 days grouped by day) */ getAIUsageHistory: adminProcedure .input( z.object({ days: z.number().min(1).max(90).default(30), }) ) .query(async ({ ctx, input }) => { const startDate = new Date() startDate.setDate(startDate.getDate() - input.days) startDate.setHours(0, 0, 0, 0) const logs = await ctx.prisma.aIUsageLog.findMany({ where: { createdAt: { gte: startDate }, }, select: { createdAt: true, totalTokens: true, estimatedCostUsd: true, action: true, }, orderBy: { createdAt: 'asc' }, }) // Group by day const dailyData: Record = {} for (const log of logs) { const dateKey = log.createdAt.toISOString().split('T')[0] if (!dailyData[dateKey]) { dailyData[dateKey] = { date: dateKey, tokens: 0, cost: 0, count: 0 } } dailyData[dateKey].tokens += log.totalTokens dailyData[dateKey].cost += log.estimatedCostUsd?.toNumber() ?? 0 dailyData[dateKey].count += 1 } return Object.values(dailyData).map((day) => ({ ...day, costFormatted: formatCost(day.cost), })) }), })