Download apps/api/src/routes/super-admin.ts from safetrack/edtech: direct link, hf CLI and curl.
- Browser
- Download file 40.4 kB
-
https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/api/src/routes/super-admin.ts
- Command line
-
hf download hf://spaces/safetrack/edtech/apps/api/src/routes/super-admin.ts
-
curl -L -o super-admin.ts https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/api/src/routes/super-admin.ts
40.4 kB
| 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' }); | |
| } | |
| }); | |
| // ββ Platform Stats ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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' }); | |
| } | |
| }); | |
| // ββ Organizations βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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' }); | |
| } | |
| }); | |
| // Suspend / activate shortcut | |
| 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' }); | |
| } | |
| }); | |
| // Soft-delete β sets subscriptionStatus to DELETED and hard-stops | |
| 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' }); | |
| } | |
| }); | |
| // Impersonation β returns a short-lived JWT scoped to the target org | |
| 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' }); | |
| } | |
| }); | |
| // ββ Users (cross-org) βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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' }); | |
| } | |
| }); | |
| // Reset password β generates a 1h JWT reset token and emails a link | |
| 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' }); | |
| } | |
| }); | |
| // ββ WhatsApp Numbers (cross-org) ββββββββββββββββββββββββββββββββββββββββββ | |
| 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' }); | |
| } | |
| }); | |
| // ββ WhatsApp Number Registration (OTP flow) βββββββββββββββββββββββββββββββ | |
| // Step 1 β Trigger Meta to send OTP to the phone number | |
| 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 }); | |
| } | |
| }); | |
| // Step 2 β Verify OTP sent by Meta | |
| 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 }); | |
| } | |
| }); | |
| // ββ WhatsApp Template Creation (cross-org) ββββββββββββββββββββββββββββββββ | |
| 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 }); | |
| } | |
| }); | |
| // ββ Billing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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' }); | |
| } | |
| }); | |
| // ββ Monitoring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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), | |
| ]); | |
| // Token expiry check β find orgs with systemUserTokenIssuedAt older than 50 days | |
| 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 } | |
| }); | |
| // Low wallet balance | |
| 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' }); | |
| } | |
| }); | |
| // ββ AI Agentic Command ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 confirming a pending action | |
| if (confirm && pendingAction) { | |
| return executeSuperAdminAction(pendingAction.action, pendingAction.params, req, reply); | |
| } | |
| // Parse command with AI | |
| 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' }); | |
| } | |
| }); | |
| // ββ Audit Logs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 }; | |
| }); | |
| // ββ WhatsApp Profiles βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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; | |
| }); | |
| // ββ WhatsApp Templates (cross-org) ββββββββββββββββββββββββββββββββββββββββ | |
| 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}` }); | |
| // When matched by name (not by unambiguous ID), require exact match or surface a confirmation | |
| 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}` }); | |
| // When matched by name (not by unambiguous ID), require exact match or surface a confirmation | |
| 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' }); | |
| } | |
| } | |