377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
|
|
import { z } from 'zod'
|
||
|
|
import { router, adminProcedure, superAdminProcedure, protectedProcedure } from '../trpc'
|
||
|
|
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
|
||
|
|
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
testAIConnection: superAdminProcedure.mutation(async ({ ctx }) => {
|
||
|
|
const apiKeySetting = await ctx.prisma.systemSettings.findUnique({
|
||
|
|
where: { key: 'openai_api_key' },
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!apiKeySetting?.value) {
|
||
|
|
return { success: false, error: 'API key not configured' }
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Test OpenAI connection with a minimal request
|
||
|
|
const response = await fetch('https://api.openai.com/v1/models', {
|
||
|
|
headers: {
|
||
|
|
Authorization: `Bearer ${apiKeySetting.value}`,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
return { success: true }
|
||
|
|
} else {
|
||
|
|
const error = await response.json()
|
||
|
|
return { success: false, error: error.error?.message || 'Unknown error' }
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
return { success: false, error: 'Connection failed' }
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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])
|
||
|
|
),
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
})
|