Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
376
src/server/routers/settings.ts
Normal file
376
src/server/routers/settings.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
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])
|
||||
),
|
||||
}
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user