506 lines
14 KiB
TypeScript
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),
|
|
}))
|
|
}),
|
|
})
|