| import { FastifyInstance } from 'fastify'; |
| import { Prisma } from '@repo/database'; |
| import { prisma } from '../services/prisma'; |
| import { logger } from '../logger'; |
| import { z } from 'zod'; |
| import { redis } from '../lib/redis'; |
| import { whatsappQueue } from '../services/queue'; |
|
|
| export async function superAdminRoutes(fastify: FastifyInstance) { |
|
|
| fastify.addHook('preHandler', async (request: any, reply) => { |
| const role = request.user?.role; |
| if (role !== 'SUPER_ADMIN') { |
| return reply.code(403).send({ error: 'Super admin access required' }); |
| } |
| }); |
|
|
| |
| fastify.get('/platform/stats', async (_req, reply) => { |
| try { |
| const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000); |
| const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); |
|
|
| const [orgsCount, usersCount, messagesLast24h, revenueAgg, queueCounts] = await Promise.all([ |
| prisma.organization.count(), |
| prisma.user.count({ where: { deletedAt: null } }), |
| prisma.message.count({ where: { createdAt: { gte: since24h } } }), |
| prisma.walletTransaction.aggregate({ |
| where: { type: 'TOP_UP_MANUAL', createdAt: { gte: since30d } }, |
| _sum: { amount: true } |
| }), |
| Promise.all([ |
| whatsappQueue.getWaitingCount(), |
| whatsappQueue.getFailedCount(), |
| whatsappQueue.getActiveCount(), |
| ]).catch(() => [0, 0, 0]), |
| ]); |
|
|
| const activeOrgs = await prisma.organization.count({ |
| where: { isHardStopped: false } |
| }); |
|
|
| return { |
| orgsCount, |
| activeOrgs, |
| usersCount, |
| messagesLast24h, |
| queueDepth: Array.isArray(queueCounts) ? (queueCounts[0] + queueCounts[2]) : 0, |
| queueFailed: Array.isArray(queueCounts) ? queueCounts[1] : 0, |
| revenueThisMonth: revenueAgg._sum.amount ?? 0, |
| }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] platform/stats failed'); |
| return reply.code(500).send({ error: 'Failed to fetch platform stats' }); |
| } |
| }); |
|
|
| |
| fastify.get('/organizations', async (req, reply) => { |
| const QuerySchema = z.object({ |
| page: z.coerce.number().int().positive().default(1), |
| limit: z.coerce.number().int().positive().max(100).default(20), |
| search: z.string().optional(), |
| plan: z.string().optional(), |
| }); |
| const parsed = QuerySchema.safeParse(req.query); |
| if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' }); |
|
|
| const { page, limit, search, plan } = parsed.data; |
| const where: any = {}; |
| if (search) where.name = { contains: search, mode: 'insensitive' }; |
| if (plan) where.subscriptionPlan = plan; |
|
|
| try { |
| const [orgs, total] = await Promise.all([ |
| prisma.organization.findMany({ |
| where, |
| select: { |
| id: true, name: true, wabaId: true, walletBalance: true, |
| subscriptionPlan: true, subscriptionStatus: true, isHardStopped: true, |
| createdAt: true, mode: true, isCrmActive: true, isEdTechActive: true, |
| _count: { select: { users: true } } |
| }, |
| orderBy: { createdAt: 'desc' }, |
| skip: (page - 1) * limit, |
| take: limit, |
| }), |
| prisma.organization.count({ where }), |
| ]); |
| return { data: orgs.map(o => ({ ...o, userCount: o._count.users })), total, page, limit }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] organizations list failed'); |
| return reply.code(500).send({ error: 'Failed to list organizations' }); |
| } |
| }); |
|
|
| fastify.post('/organizations', async (req, reply) => { |
| const Schema = z.object({ |
| name: z.string().min(1), |
| subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).default('STARTER'), |
| aiCreditsLimit: z.number().int().positive().default(500), |
| isCrmActive: z.boolean().default(false), |
| isEdTechActive: z.boolean().default(true), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| try { |
| const org = await prisma.organization.create({ data: body.data }); |
| return reply.code(201).send(org); |
| } catch (err: any) { |
| logger.error({ err }, '[SUPER_ADMIN] org create failed'); |
| return reply.code(500).send({ error: 'Failed to create organization' }); |
| } |
| }); |
|
|
| fastify.patch<{ Params: { id: string } }>('/organizations/:id', async (req, reply) => { |
| const Schema = z.object({ |
| name: z.string().min(1).optional(), |
| subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).optional(), |
| subscriptionStatus: z.string().optional(), |
| aiCreditsLimit: z.number().int().positive().optional(), |
| isHardStopped: z.boolean().optional(), |
| isCrmActive: z.boolean().optional(), |
| isEdTechActive: z.boolean().optional(), |
| walletBalance: z.number().int().optional(), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| try { |
| const org = await prisma.organization.update({ |
| where: { id: req.params.id }, |
| data: body.data, |
| }); |
| return org; |
| } catch (err: any) { |
| logger.error({ err, orgId: req.params.id }, '[SUPER_ADMIN] org update failed'); |
| if (err.code === 'P2025') return reply.code(404).send({ error: 'Organization not found' }); |
| return reply.code(500).send({ error: 'Failed to update organization' }); |
| } |
| }); |
|
|
| |
| fastify.post<{ Params: { id: string } }>('/organizations/:id/suspend', async (req, reply) => { |
| const Schema = z.object({ suspend: z.boolean() }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
| try { |
| await prisma.organization.update({ |
| where: { id: req.params.id }, |
| data: { isHardStopped: body.data.suspend, subscriptionStatus: body.data.suspend ? 'SUSPENDED' : 'ACTIVE' } |
| }); |
| return { ok: true }; |
| } catch (err: any) { |
| if (err.code === 'P2025') return reply.code(404).send({ error: 'Organization not found' }); |
| return reply.code(500).send({ error: 'Failed to suspend organization' }); |
| } |
| }); |
|
|
| |
| fastify.delete<{ Params: { id: string } }>('/organizations/:id', async (req, reply) => { |
| try { |
| const org = await prisma.organization.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } }); |
| if (!org) return reply.code(404).send({ error: 'Organization not found' }); |
| await prisma.organization.update({ |
| where: { id: req.params.id }, |
| data: { subscriptionStatus: 'DELETED', isHardStopped: true }, |
| }); |
| logger.info({ orgId: req.params.id, actor: (req as any).user?.id }, '[SUPER_ADMIN] Organization soft-deleted'); |
| return { ok: true }; |
| } catch (err) { |
| return reply.code(500).send({ error: 'Delete failed' }); |
| } |
| }); |
|
|
| |
| fastify.post<{ Params: { id: string } }>('/organizations/:id/impersonate', async (req, reply) => { |
| try { |
| const org = await prisma.organization.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } }); |
| if (!org) return reply.code(404).send({ error: 'Organization not found' }); |
|
|
| const impersonateToken = (fastify as any).jwt.sign( |
| { id: (req as any).user.id, role: 'ORG_ADMIN', organizationId: org.id, impersonating: true }, |
| { expiresIn: '1h' } |
| ); |
| return { token: impersonateToken, orgName: org.name }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] impersonate failed'); |
| return reply.code(500).send({ error: 'Impersonation failed' }); |
| } |
| }); |
|
|
| |
| fastify.get('/users', async (req, reply) => { |
| const QuerySchema = z.object({ |
| page: z.coerce.number().int().positive().default(1), |
| limit: z.coerce.number().int().positive().max(100).default(20), |
| orgId: z.string().optional(), |
| role: z.string().optional(), |
| search: z.string().optional(), |
| }); |
| const parsed = QuerySchema.safeParse(req.query); |
| if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' }); |
|
|
| const { page, limit, orgId, role, search } = parsed.data; |
| const where: any = { deletedAt: null }; |
| if (orgId) where.organizationId = orgId; |
| if (role) where.role = role; |
| if (search) where.OR = [ |
| { name: { contains: search, mode: 'insensitive' } }, |
| { email: { contains: search, mode: 'insensitive' } }, |
| ]; |
|
|
| try { |
| const [users, total] = await Promise.all([ |
| prisma.user.findMany({ |
| where, |
| select: { id: true, name: true, email: true, phone: true, role: true, organizationId: true, createdAt: true, lastActivityAt: true, |
| organization: { select: { name: true } } |
| }, |
| orderBy: { createdAt: 'desc' }, |
| skip: (page - 1) * limit, |
| take: limit, |
| }), |
| prisma.user.count({ where }), |
| ]); |
| return { data: users, total, page, limit }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] users list failed'); |
| return reply.code(500).send({ error: 'Failed to list users' }); |
| } |
| }); |
|
|
| fastify.patch<{ Params: { userId: string } }>('/users/:userId/role', async (req, reply) => { |
| const Schema = z.object({ role: z.enum(['STUDENT', 'ORG_MEMBER', 'ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN']) }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
| try { |
| const user = await prisma.user.update({ where: { id: req.params.userId }, data: { role: body.data.role } }); |
| return { ok: true, role: user.role }; |
| } catch (err: any) { |
| if (err.code === 'P2025') return reply.code(404).send({ error: 'User not found' }); |
| return reply.code(500).send({ error: 'Failed to update role' }); |
| } |
| }); |
|
|
| |
| fastify.post<{ Params: { userId: string } }>('/users/:userId/reset-password', async (req, reply) => { |
| try { |
| const user = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, email: true, name: true } }); |
| if (!user) return reply.code(404).send({ error: 'User not found' }); |
| if (!user.email) return reply.code(400).send({ error: 'User has no email address' }); |
|
|
| const resetToken = (fastify as any).jwt.sign( |
| { id: user.id, purpose: 'reset' }, |
| { expiresIn: '1h' } |
| ) as string; |
| const adminUrl = process.env.ADMIN_URL || 'https://edtechadmin.netlify.app'; |
| const resetLink = `${adminUrl}/reset-password?token=${resetToken}`; |
|
|
| const { scheduleEmail } = await import('../services/queue'); |
| await scheduleEmail({ |
| to: user.email, |
| subject: 'Réinitialisation de votre mot de passe — XAMLÉ (admin)', |
| htmlContent: `<p>Bonjour ${user.name ?? ''},</p> |
| <p>Un administrateur a déclenché une réinitialisation de votre mot de passe. Cliquez ci-dessous pour définir un nouveau mot de passe. Ce lien expire dans <strong>1 heure</strong>.</p> |
| <p><a href="${resetLink}" style="background:#4f46e5;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;">Réinitialiser mon mot de passe</a></p> |
| <p>Si vous n'attendiez pas cet email, contactez votre administrateur.</p>`, |
| }); |
|
|
| logger.info({ userId: user.id, actor: (req as any).user?.id }, '[SUPER_ADMIN] Password reset email sent'); |
| return { ok: true, email: user.email }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] reset-password failed'); |
| return reply.code(500).send({ error: 'Failed to send reset email' }); |
| } |
| }); |
|
|
| |
| fastify.get('/whatsapp/numbers', async (_req, reply) => { |
| try { |
| const numbers = await prisma.whatsAppPhoneNumber.findMany({ |
| include: { organization: { select: { id: true, name: true } } }, |
| orderBy: { createdAt: 'desc' }, |
| }); |
| return { data: numbers }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] whatsapp numbers failed'); |
| return reply.code(500).send({ error: 'Failed to list phone numbers' }); |
| } |
| }); |
|
|
| |
|
|
| |
| fastify.post('/whatsapp/numbers/register', async (req, reply) => { |
| const Schema = z.object({ |
| orgId: z.string(), |
| phoneNumberId: z.string(), |
| pin: z.string().length(6).regex(/^\d{6}$/).default('000000'), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| const { orgId, phoneNumberId, pin } = body.data; |
| try { |
| const { decryptSecrets } = await import('../services/organization'); |
| const { whatsappService } = await import('../services/whatsapp'); |
| const org = await prisma.organization.findUnique({ |
| where: { id: orgId }, |
| select: { id: true, name: true, systemUserToken: true, wabaId: true, systemUserTokenIssuedAt: true, webhookSecret: true }, |
| }); |
| if (!org) return reply.code(404).send({ error: 'Organization not found' }); |
| if (!org.systemUserToken) return reply.code(400).send({ error: 'Organization has no systemUserToken configured' }); |
|
|
| const decrypted = decryptSecrets(org as any); |
| const result = await whatsappService.registerPhoneNumber({ accessToken: decrypted.systemUserToken! }, phoneNumberId, pin); |
|
|
| logger.info({ orgId, phoneNumberId, actor: (req as any).user?.id }, '[SUPER_ADMIN] Phone number registration initiated'); |
| return { ok: true, metaResponse: result }; |
| } catch (err: any) { |
| const detail = err.response?.data?.error?.message ?? err.message; |
| return reply.code(502).send({ error: 'Meta API error', detail }); |
| } |
| }); |
|
|
| |
| fastify.post('/whatsapp/numbers/verify', async (req, reply) => { |
| const Schema = z.object({ |
| orgId: z.string(), |
| phoneNumberId: z.string(), |
| code: z.string().min(4).max(8), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| const { orgId, phoneNumberId, code } = body.data; |
| try { |
| const { decryptSecrets } = await import('../services/organization'); |
| const { whatsappService } = await import('../services/whatsapp'); |
| const org = await prisma.organization.findUnique({ |
| where: { id: orgId }, |
| select: { id: true, name: true, systemUserToken: true, webhookSecret: true, systemUserTokenIssuedAt: true }, |
| }); |
| if (!org) return reply.code(404).send({ error: 'Organization not found' }); |
| if (!org.systemUserToken) return reply.code(400).send({ error: 'Organization has no systemUserToken configured' }); |
|
|
| const decrypted = decryptSecrets(org as any); |
| const result = await whatsappService.verifyPhoneNumber({ accessToken: decrypted.systemUserToken! }, phoneNumberId, code); |
|
|
| logger.info({ orgId, phoneNumberId, actor: (req as any).user?.id }, '[SUPER_ADMIN] Phone number verified'); |
| return { ok: true, metaResponse: result }; |
| } catch (err: any) { |
| const detail = err.response?.data?.error?.message ?? err.message; |
| return reply.code(502).send({ error: 'Meta verification failed', detail }); |
| } |
| }); |
|
|
| |
| fastify.post('/whatsapp/templates', async (req, reply) => { |
| const Schema = z.object({ |
| orgId: z.string(), |
| name: z.string().min(1).regex(/^[a-z0-9_]+$/, 'Template name must be lowercase snake_case'), |
| category: z.enum(['MARKETING', 'UTILITY', 'AUTHENTICATION']), |
| language: z.string().min(2), |
| body: z.string().min(1).max(1024), |
| header: z.string().max(60).optional(), |
| footer: z.string().max(60).optional(), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| const { orgId, name, category, language, body: bodyText, header, footer } = body.data; |
| try { |
| const { decryptSecrets } = await import('../services/organization'); |
| const { whatsappService } = await import('../services/whatsapp'); |
| const org = await prisma.organization.findUnique({ |
| where: { id: orgId }, |
| select: { id: true, name: true, wabaId: true, systemUserToken: true, webhookSecret: true, systemUserTokenIssuedAt: true }, |
| }); |
| if (!org) return reply.code(404).send({ error: 'Organization not found' }); |
| if (!org.wabaId || !org.systemUserToken) return reply.code(400).send({ error: 'Organization WhatsApp not configured' }); |
|
|
| const decrypted = decryptSecrets(org as any); |
|
|
| const components: any[] = []; |
| if (header) components.push({ type: 'HEADER', format: 'TEXT', text: header }); |
| components.push({ type: 'BODY', text: bodyText }); |
| if (footer) components.push({ type: 'FOOTER', text: footer }); |
|
|
| const result = await whatsappService.createMetaTemplate( |
| { accessToken: decrypted.systemUserToken!, wabaId: org.wabaId }, |
| { name, category, language, components } |
| ); |
|
|
| await prisma.auditLog.create({ |
| data: { |
| action: 'SUPER_ADMIN_CREATE_TEMPLATE', |
| actorId: (req as any).user?.id, |
| resourceId: orgId, |
| details: { templateName: name, category, language }, |
| }, |
| }); |
|
|
| logger.info({ orgId, templateName: name, actor: (req as any).user?.id }, '[SUPER_ADMIN] Template created'); |
| return { ok: true, template: result }; |
| } catch (err: any) { |
| const detail = err.response?.data?.error?.message ?? err.message; |
| return reply.code(502).send({ error: 'Meta API error', detail }); |
| } |
| }); |
|
|
| |
| fastify.get('/billing/transactions', async (req, reply) => { |
| const QuerySchema = z.object({ |
| page: z.coerce.number().int().positive().default(1), |
| limit: z.coerce.number().int().positive().max(100).default(20), |
| orgId: z.string().optional(), |
| }); |
| const parsed = QuerySchema.safeParse(req.query); |
| if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' }); |
|
|
| const { page, limit, orgId } = parsed.data; |
| const where: any = {}; |
| if (orgId) where.organizationId = orgId; |
|
|
| try { |
| const [txns, total] = await Promise.all([ |
| prisma.walletTransaction.findMany({ |
| where, |
| include: { organization: { select: { id: true, name: true } } }, |
| orderBy: { createdAt: 'desc' }, |
| skip: (page - 1) * limit, |
| take: limit, |
| }), |
| prisma.walletTransaction.count({ where }), |
| ]); |
| return { data: txns, total, page, limit }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] billing transactions failed'); |
| return reply.code(500).send({ error: 'Failed to list transactions' }); |
| } |
| }); |
|
|
| fastify.post('/billing/credits', async (req, reply) => { |
| const Schema = z.object({ |
| orgId: z.string(), |
| amount: z.number().int().positive(), |
| description: z.string().optional(), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| try { |
| const orgExists = await prisma.organization.findUnique({ where: { id: body.data.orgId }, select: { id: true } }); |
| if (!orgExists) return reply.code(404).send({ error: 'Organization not found' }); |
|
|
| const { orgId, amount, description } = body.data; |
| const updated = await prisma.$transaction(async (tx) => { |
| const org = await tx.organization.update({ |
| where: { id: orgId }, |
| data: { walletBalance: { increment: amount } }, |
| select: { walletBalance: true, name: true }, |
| }); |
| const txn = await tx.walletTransaction.create({ |
| data: { |
| organizationId: orgId, |
| amount, |
| balanceAfter: org.walletBalance, |
| type: 'TOP_UP_MANUAL', |
| description: description || 'Manual credit by super-admin', |
| actorId: (req as any).user?.id, |
| }, |
| }); |
| return { org, txn }; |
| }); |
| return { ok: true, newBalance: updated.org.walletBalance, transactionId: updated.txn.id }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] add credits failed'); |
| return reply.code(500).send({ error: 'Failed to add credits' }); |
| } |
| }); |
|
|
| |
| fastify.get('/monitoring/health', async (_req, reply) => { |
| try { |
| const [dbPing, redisPing, queueWaiting, queueFailed, queueActive] = await Promise.all([ |
| prisma.$queryRaw`SELECT 1`.then(() => true).catch(() => false), |
| redis.ping().then(() => true).catch(() => false), |
| whatsappQueue.getWaitingCount().catch(() => -1), |
| whatsappQueue.getFailedCount().catch(() => -1), |
| whatsappQueue.getActiveCount().catch(() => -1), |
| ]); |
|
|
| |
| const tokenExpiryThreshold = new Date(Date.now() - 50 * 24 * 60 * 60 * 1000); |
| const expiringTokenOrgs = await prisma.organization.findMany({ |
| where: { |
| systemUserTokenIssuedAt: { lte: tokenExpiryThreshold }, |
| systemUserToken: { not: null } |
| }, |
| select: { id: true, name: true, systemUserTokenIssuedAt: true } |
| }); |
|
|
| |
| const lowBalanceOrgs = await prisma.organization.findMany({ |
| where: { walletBalance: { lt: 100 }, isHardStopped: false }, |
| select: { id: true, name: true, walletBalance: true } |
| }); |
|
|
| return { |
| db: { ok: dbPing }, |
| redis: { ok: redisPing }, |
| queue: { waiting: queueWaiting, failed: queueFailed, active: queueActive }, |
| tokenExpiries: expiringTokenOrgs.map(o => ({ |
| orgId: o.id, |
| orgName: o.name, |
| issuedAt: o.systemUserTokenIssuedAt, |
| daysOld: o.systemUserTokenIssuedAt |
| ? Math.floor((Date.now() - o.systemUserTokenIssuedAt.getTime()) / (24 * 60 * 60 * 1000)) |
| : null |
| })), |
| lowBalanceOrgs, |
| }; |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] monitoring failed'); |
| return reply.code(500).send({ error: 'Failed to fetch monitoring data' }); |
| } |
| }); |
|
|
| |
| fastify.post('/ai/command', async (req, reply) => { |
| const Schema = z.object({ |
| command: z.string().min(1), |
| confirm: z.boolean().default(false), |
| pendingAction: z.object({ |
| action: z.string(), |
| params: z.record(z.unknown()), |
| }).optional(), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| const { command, confirm, pendingAction } = body.data; |
|
|
| |
| if (confirm && pendingAction) { |
| return executeSuperAdminAction(pendingAction.action, pendingAction.params, req, reply); |
| } |
|
|
| |
| const { aiService } = await import('../services/ai'); |
| const { z: zod } = await import('zod'); |
|
|
| const CommandSchema = zod.object({ |
| action: zod.enum(['QUERY_STATS', 'LIST_ORGS', 'SUSPEND_ORG', 'ACTIVATE_ORG', 'ADD_CREDITS', 'LIST_LOW_BALANCE', 'LIST_ALERTS', 'LIST_USERS']), |
| params: zod.record(zod.unknown()).default({}), |
| confirmation_required: zod.boolean(), |
| human_summary: zod.string(), |
| }); |
|
|
| try { |
| const systemPrompt = `You are a super-admin AI assistant for the XAMLÉ platform. Parse the admin's command and return a structured action. |
| Available actions: |
| - QUERY_STATS: Get platform statistics (no params needed) |
| - LIST_ORGS: List organizations (params: search?, plan?) |
| - SUSPEND_ORG: Suspend an organization (params: orgName or orgId) — confirmation required |
| - ACTIVATE_ORG: Reactivate a suspended org (params: orgName or orgId) — confirmation required |
| - ADD_CREDITS: Add wallet credits to an org (params: orgName or orgId, amount) — confirmation required |
| - LIST_LOW_BALANCE: List orgs with low wallet balance |
| - LIST_ALERTS: List monitoring alerts |
| - LIST_USERS: List users (params: orgName?, role?) |
| |
| Always extract organization names and amounts from the command. Set confirmation_required=true for destructive or financial actions.`; |
|
|
| const { data: parsed } = await aiService.generateStructuredData( |
| `${systemPrompt}\n\nAdmin command: "${command}"`, |
| CommandSchema, |
| 0.1 |
| ); |
|
|
| if (parsed.confirmation_required) { |
| return { |
| status: 'pending_confirmation', |
| summary: parsed.human_summary, |
| action: parsed.action, |
| params: parsed.params, |
| }; |
| } |
|
|
| return executeSuperAdminAction(parsed.action, parsed.params ?? {}, req, reply); |
| } catch (err) { |
| logger.error({ err }, '[SUPER_ADMIN] AI command failed'); |
| return reply.code(503).send({ error: 'AI service unavailable' }); |
| } |
| }); |
|
|
| |
| fastify.get('/audit-logs', async (req, _reply) => { |
| const q = req.query as { page?: string; limit?: string; orgId?: string; actorId?: string; action?: string; from?: string; to?: string }; |
| const page = Math.max(1, parseInt(q.page ?? '1')); |
| const limit = Math.min(100, parseInt(q.limit ?? '50')); |
|
|
| const where: any = {}; |
| if (q.orgId) where.resourceId = q.orgId; |
| if (q.actorId) where.actorId = q.actorId; |
| if (q.action) where.action = { contains: q.action, mode: 'insensitive' }; |
| if (q.from || q.to) { |
| where.createdAt = {}; |
| if (q.from) where.createdAt.gte = new Date(q.from); |
| if (q.to) where.createdAt.lte = new Date(q.to); |
| } |
|
|
| const [logs, total] = await Promise.all([ |
| prisma.auditLog.findMany({ |
| where, |
| orderBy: { createdAt: 'desc' }, |
| skip: (page - 1) * limit, |
| take: limit, |
| }), |
| prisma.auditLog.count({ where }), |
| ]); |
|
|
| return { logs, total, page, limit }; |
| }); |
|
|
| |
| fastify.get('/whatsapp/profiles', async (_req, _reply) => { |
| const orgs = await prisma.organization.findMany({ |
| orderBy: { createdAt: 'desc' }, |
| select: { |
| id: true, name: true, wabaId: true, |
| brandingData: true, subscriptionPlan: true, subscriptionStatus: true, |
| systemUserTokenIssuedAt: true, createdAt: true, |
| }, |
| }); |
| return { profiles: orgs }; |
| }); |
|
|
| fastify.patch<{ Params: { id: string } }>('/whatsapp/profiles/:id', async (req, reply) => { |
| const Schema = z.object({ |
| name: z.string().min(1).optional(), |
| brandingData: z.record(z.unknown()).optional(), |
| }); |
| const body = Schema.safeParse(req.body); |
| if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); |
|
|
| const org = await prisma.organization.findUnique({ where: { id: req.params.id } }); |
| if (!org) return reply.code(404).send({ error: 'Organization not found' }); |
|
|
| const updated = await prisma.organization.update({ |
| where: { id: req.params.id }, |
| data: { |
| ...(body.data.name && { name: body.data.name }), |
| ...(body.data.brandingData && { brandingData: body.data.brandingData as Prisma.InputJsonValue }), |
| }, |
| select: { id: true, name: true, brandingData: true }, |
| }); |
| return updated; |
| }); |
|
|
| |
| fastify.get('/whatsapp/templates', async (_req, _reply) => { |
| const orgs = await prisma.organization.findMany({ |
| where: { wabaId: { not: null }, subscriptionStatus: 'ACTIVE' }, |
| select: { id: true, name: true, wabaId: true, subscriptionPlan: true }, |
| orderBy: { name: 'asc' }, |
| }); |
| return { orgs }; |
| }); |
| } |
|
|
| async function executeSuperAdminAction(action: string, params: Record<string, unknown>, req: any, reply: any) { |
| try { |
| switch (action) { |
| case 'QUERY_STATS': { |
| const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000); |
| const [orgsCount, usersCount, messagesLast24h] = await Promise.all([ |
| prisma.organization.count(), |
| prisma.user.count({ where: { deletedAt: null } }), |
| prisma.message.count({ where: { createdAt: { gte: since24h } } }), |
| ]); |
| return { status: 'success', result: { orgsCount, usersCount, messagesLast24h } }; |
| } |
| case 'LIST_ORGS': { |
| const orgs = await prisma.organization.findMany({ |
| where: params.search ? { name: { contains: params.search as string, mode: 'insensitive' } } : undefined, |
| select: { id: true, name: true, subscriptionPlan: true, walletBalance: true, isHardStopped: true }, |
| take: 20, |
| orderBy: { createdAt: 'desc' }, |
| }); |
| return { status: 'success', result: orgs }; |
| } |
| case 'LIST_LOW_BALANCE': { |
| const orgs = await prisma.organization.findMany({ |
| where: { walletBalance: { lt: 100 } }, |
| select: { id: true, name: true, walletBalance: true }, |
| orderBy: { walletBalance: 'asc' }, |
| }); |
| return { status: 'success', result: orgs }; |
| } |
| case 'LIST_ALERTS': { |
| const tokenThreshold = new Date(Date.now() - 50 * 24 * 60 * 60 * 1000); |
| const [expiringTokens, lowBalance] = await Promise.all([ |
| prisma.organization.findMany({ |
| where: { systemUserTokenIssuedAt: { lte: tokenThreshold }, systemUserToken: { not: null } }, |
| select: { id: true, name: true, systemUserTokenIssuedAt: true }, |
| }), |
| prisma.organization.findMany({ |
| where: { walletBalance: { lt: 100 } }, |
| select: { id: true, name: true, walletBalance: true }, |
| }), |
| ]); |
| return { status: 'success', result: { expiringTokens, lowBalance } }; |
| } |
| case 'SUSPEND_ORG': |
| case 'ACTIVATE_ORG': { |
| const suspend = action === 'SUSPEND_ORG'; |
| const orgSearch = (params.orgName || params.orgId) as string; |
| const org = await prisma.organization.findFirst({ |
| where: params.orgId |
| ? { id: params.orgId as string } |
| : { name: { contains: orgSearch, mode: 'insensitive' } } |
| }); |
| if (!org) return reply.code(404).send({ error: `Organization not found: ${orgSearch}` }); |
| |
| if (!params.orgId) { |
| const exactMatch = org.name.toLowerCase() === (orgSearch as string).toLowerCase(); |
| if (!exactMatch) { |
| return { |
| status: 'pending_confirmation', |
| summary: `Found org '${org.name}' — is this the right one? Confirm to proceed.`, |
| action, |
| params: { ...params, orgId: org.id }, |
| }; |
| } |
| } |
| await prisma.organization.update({ |
| where: { id: org.id }, |
| data: { isHardStopped: suspend, subscriptionStatus: suspend ? 'SUSPENDED' : 'ACTIVE' } |
| }); |
| return { status: 'success', result: { orgId: org.id, orgName: org.name, action: suspend ? 'suspended' : 'activated' } }; |
| } |
| case 'ADD_CREDITS': { |
| const orgSearch = (params.orgName || params.orgId) as string; |
| const amount = typeof params.amount === 'number' ? params.amount : parseInt(params.amount as string, 10); |
| if (!amount || amount <= 0) return reply.code(400).send({ error: 'Invalid amount' }); |
| const org = await prisma.organization.findFirst({ |
| where: params.orgId |
| ? { id: params.orgId as string } |
| : { name: { contains: orgSearch, mode: 'insensitive' } } |
| }); |
| if (!org) return reply.code(404).send({ error: `Organization not found: ${orgSearch}` }); |
| |
| if (!params.orgId) { |
| const exactMatch = org.name.toLowerCase() === (orgSearch as string).toLowerCase(); |
| if (!exactMatch) { |
| return { |
| status: 'pending_confirmation', |
| summary: `Found org '${org.name}' — is this the right one? Confirm to proceed.`, |
| action, |
| params: { ...params, orgId: org.id }, |
| }; |
| } |
| } |
| const newBalance = org.walletBalance + amount; |
| await prisma.$transaction([ |
| prisma.organization.update({ where: { id: org.id }, data: { walletBalance: newBalance } }), |
| prisma.walletTransaction.create({ |
| data: { |
| organizationId: org.id, |
| amount, |
| balanceAfter: newBalance, |
| type: 'TOP_UP_MANUAL', |
| description: `Super-admin AI command: add ${amount} credits`, |
| actorId: req.user?.id, |
| } |
| }) |
| ]); |
| return { status: 'success', result: { orgId: org.id, orgName: org.name, amount, newBalance } }; |
| } |
| case 'LIST_USERS': { |
| const where: any = { deletedAt: null }; |
| if (params.role) where.role = params.role; |
| if (params.orgName) { |
| const org = await prisma.organization.findFirst({ where: { name: { contains: params.orgName as string, mode: 'insensitive' } } }); |
| if (org) where.organizationId = org.id; |
| } |
| const users = await prisma.user.findMany({ where, select: { id: true, name: true, email: true, role: true, organizationId: true }, take: 20 }); |
| return { status: 'success', result: users }; |
| } |
| default: |
| return reply.code(400).send({ error: `Unknown action: ${action}` }); |
| } |
| } catch (err) { |
| logger.error({ err, action }, '[SUPER_ADMIN] action execution failed'); |
| return reply.code(500).send({ error: 'Action execution failed' }); |
| } |
| } |
|
|