MOPC-App/src/server/routers/settings.ts

506 lines
14 KiB
TypeScript

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<string, unknown> = {}
if (input.startDate || input.endDate) {
where.createdAt = {}
if (input.startDate) {
(where.createdAt as Record<string, Date>).gte = new Date(input.startDate)
}
if (input.endDate) {
(where.createdAt as Record<string, Date>).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<string, { date: string; tokens: number; cost: number; count: number }> = {}
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),
}))
}),
})