386 lines
16 KiB
JavaScript
386 lines
16 KiB
JavaScript
"use strict";
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.adminRouter = void 0;
|
|
/**
|
|
* Rotas exclusivas do ADMIN (dono do SaaS).
|
|
* Prefixo: /api/admin/
|
|
*/
|
|
const path_1 = __importDefault(require("path"));
|
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
const express_1 = require("express");
|
|
const zod_1 = require("zod");
|
|
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
|
const multer_1 = __importDefault(require("multer"));
|
|
const systeminformation_1 = __importDefault(require("systeminformation"));
|
|
const prisma_1 = require("../../infra/database/prisma");
|
|
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
|
const plugins_routes_1 = require("../plugins/plugins.routes");
|
|
const SETTINGS_KEY = 'system:settings';
|
|
const DEFAULT_SETTINGS = {
|
|
systemName: 'NewWhats',
|
|
timezone: 'America/Sao_Paulo',
|
|
logoUrl: null,
|
|
faviconUrl: null,
|
|
accentColor: '#3b82f6',
|
|
sounds: { newMessage: true, notification: true, connectionStatus: true },
|
|
smtp: { host: '', port: 587, user: '', password: '', from: '', secure: false },
|
|
emailTemplates: { welcome: '', passwordReset: '', trialExpiring: '' },
|
|
registration: { mode: 'open', defaultPlanId: null },
|
|
maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' },
|
|
webhook: { url: '', secret: '' },
|
|
};
|
|
async function getSettings() {
|
|
const stored = await dragonfly_1.dragonfly.getJson(SETTINGS_KEY);
|
|
return { ...DEFAULT_SETTINGS, ...stored };
|
|
}
|
|
const uploadAsset = (0, multer_1.default)({
|
|
storage: multer_1.default.memoryStorage(),
|
|
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
|
fileFilter: (_req, file, cb) => {
|
|
if (file.mimetype.startsWith('image/'))
|
|
cb(null, true);
|
|
else
|
|
cb(new Error('Apenas imagens são permitidas'));
|
|
},
|
|
});
|
|
exports.adminRouter = (0, express_1.Router)();
|
|
// ── Plugins ───────────────────────────────────────────────────────────────────
|
|
exports.adminRouter.use('/plugins', plugins_routes_1.pluginsRouter);
|
|
// ── Plans ─────────────────────────────────────────────────────────────────────
|
|
exports.adminRouter.get('/plans', async (_req, res) => {
|
|
try {
|
|
const plans = await prisma_1.prisma.plan.findMany({
|
|
select: { id: true, name: true }
|
|
});
|
|
res.json(plans);
|
|
}
|
|
catch {
|
|
res.status(500).json({ error: 'Erro ao listar planos' });
|
|
}
|
|
});
|
|
// ── Settings ──────────────────────────────────────────────────────────────────
|
|
exports.adminRouter.get('/settings', async (_req, res) => {
|
|
try {
|
|
const settings = await getSettings();
|
|
// Nunca expõe a senha SMTP ao frontend
|
|
const { smtp, ...rest } = settings;
|
|
res.json({ ...rest, smtp: { ...smtp, password: smtp.password ? '••••••••' : '' } });
|
|
}
|
|
catch {
|
|
res.status(500).json({ error: 'Erro ao buscar configurações' });
|
|
}
|
|
});
|
|
exports.adminRouter.patch('/settings', async (req, res) => {
|
|
try {
|
|
const current = await getSettings();
|
|
const body = req.body;
|
|
// Se o frontend devolver o placeholder de senha, mantém a senha atual
|
|
if (body.smtp?.password === '••••••••') {
|
|
body.smtp = { ...body.smtp, password: current.smtp.password };
|
|
}
|
|
const updated = { ...current, ...body };
|
|
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, updated, 0); // TTL 0 = sem expiração
|
|
res.json({ ok: true });
|
|
}
|
|
catch {
|
|
res.status(500).json({ error: 'Erro ao salvar configurações' });
|
|
}
|
|
});
|
|
// Upload de logo
|
|
exports.adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
res.status(400).json({ error: 'Arquivo não enviado' });
|
|
return;
|
|
}
|
|
const ext = req.file.originalname.split('.').pop() ?? 'png';
|
|
const dest = path_1.default.resolve('./media/system/logo.' + ext);
|
|
await promises_1.default.writeFile(dest, req.file.buffer);
|
|
const url = `/media/system/logo.${ext}`;
|
|
const settings = await getSettings();
|
|
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0);
|
|
res.json({ url });
|
|
}
|
|
catch {
|
|
res.status(500).json({ error: 'Erro ao salvar logo' });
|
|
}
|
|
});
|
|
// Upload de favicon
|
|
exports.adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
res.status(400).json({ error: 'Arquivo não enviado' });
|
|
return;
|
|
}
|
|
const ext = req.file.originalname.split('.').pop() ?? 'ico';
|
|
const dest = path_1.default.resolve('./media/system/favicon.' + ext);
|
|
await promises_1.default.writeFile(dest, req.file.buffer);
|
|
const url = `/media/system/favicon.${ext}`;
|
|
const settings = await getSettings();
|
|
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, faviconUrl: url }, 0);
|
|
res.json({ url });
|
|
}
|
|
catch {
|
|
res.status(500).json({ error: 'Erro ao salvar favicon' });
|
|
}
|
|
});
|
|
// Endpoint público para o frontend ler as settings de branding (sem autenticação)
|
|
// Usado pelo _app.tsx para aplicar nome/logo/favicon sem precisar do token
|
|
exports.adminRouter.get('/settings/public', async (_req, res) => {
|
|
try {
|
|
const { systemName, logoUrl, faviconUrl, accentColor, maintenance } = await getSettings();
|
|
res.json({ systemName, logoUrl, faviconUrl, accentColor, maintenance });
|
|
}
|
|
catch {
|
|
res.status(500).json({ error: 'Erro' });
|
|
}
|
|
});
|
|
// ── Métricas do sistema ───────────────────────────────────────────────────────
|
|
exports.adminRouter.get('/metrics', async (_req, res) => {
|
|
try {
|
|
const [cpuLoad, mem, disk] = await Promise.all([
|
|
systeminformation_1.default.currentLoad(),
|
|
systeminformation_1.default.mem(),
|
|
systeminformation_1.default.fsSize(),
|
|
]);
|
|
const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0];
|
|
const [connectedInstances, totalUsers] = await Promise.all([
|
|
prisma_1.prisma.instance.count({ where: { status: 'CONNECTED' } }),
|
|
prisma_1.prisma.user.count(),
|
|
]);
|
|
res.json({
|
|
cpu: { percent: Math.round(cpuLoad.currentLoad) },
|
|
ram: {
|
|
total: mem.total,
|
|
used: mem.used,
|
|
percent: Math.round((mem.used / mem.total) * 100),
|
|
},
|
|
disk: {
|
|
total: rootDisk?.size ?? 0,
|
|
used: rootDisk?.used ?? 0,
|
|
percent: Math.round(rootDisk?.use ?? 0),
|
|
},
|
|
uptime: Math.floor(process.uptime()),
|
|
connectedInstances,
|
|
totalUsers,
|
|
});
|
|
}
|
|
catch {
|
|
res.status(500).json({ error: 'Erro ao coletar métricas' });
|
|
}
|
|
});
|
|
// Listar todos os tenants com presença online
|
|
exports.adminRouter.get('/users', async (_req, res) => {
|
|
try {
|
|
const [users, presenceKeys] = await Promise.all([
|
|
prisma_1.prisma.user.findMany({
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
isActive: true,
|
|
planId: true,
|
|
trialEndsAt: true,
|
|
createdAt: true,
|
|
_count: { select: { instances: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
dragonfly_1.dragonfly.raw().keys('presence:user:*'),
|
|
]);
|
|
const onlineIds = new Set(presenceKeys.map((k) => k.replace('presence:user:', '')));
|
|
res.json(users.map((u) => ({ ...u, online: onlineIds.has(u.id) })));
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: 'Erro ao listar usuários' });
|
|
}
|
|
});
|
|
// Detalhes de um tenant
|
|
exports.adminRouter.get('/users/:id', async (req, res) => {
|
|
try {
|
|
const id = req.params['id'];
|
|
const user = await prisma_1.prisma.user.findUnique({
|
|
where: { id },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
isActive: true,
|
|
planId: true,
|
|
trialEndsAt: true,
|
|
createdAt: true,
|
|
instances: { select: { id: true, name: true, status: true } },
|
|
apiKeys: { select: { id: true, name: true, isActive: true, createdAt: true } },
|
|
},
|
|
});
|
|
if (!user) {
|
|
res.status(404).json({ error: 'Usuário não encontrado' });
|
|
return;
|
|
}
|
|
res.json(user);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: 'Erro ao buscar usuário' });
|
|
}
|
|
});
|
|
// Ativar / desativar tenant
|
|
exports.adminRouter.patch('/users/:id/status', async (req, res) => {
|
|
try {
|
|
const id = req.params['id'];
|
|
const { isActive } = zod_1.z.object({ isActive: zod_1.z.boolean() }).parse(req.body);
|
|
const user = await prisma_1.prisma.user.update({
|
|
where: { id },
|
|
data: { isActive },
|
|
select: { id: true, name: true, email: true, isActive: true },
|
|
});
|
|
res.json(user);
|
|
}
|
|
catch (err) {
|
|
if (err instanceof zod_1.z.ZodError) {
|
|
res.status(400).json({ error: 'isActive deve ser boolean' });
|
|
return;
|
|
}
|
|
res.status(500).json({ error: 'Erro ao atualizar status' });
|
|
}
|
|
});
|
|
// Alterar plano do tenant
|
|
exports.adminRouter.patch('/users/:id/plan', async (req, res) => {
|
|
try {
|
|
const id = req.params['id'];
|
|
const { planId } = zod_1.z.object({ planId: zod_1.z.string().uuid() }).parse(req.body);
|
|
const user = await prisma_1.prisma.user.update({
|
|
where: { id },
|
|
data: { planId },
|
|
select: { id: true, name: true, planId: true },
|
|
});
|
|
res.json(user);
|
|
}
|
|
catch (err) {
|
|
if (err instanceof zod_1.z.ZodError) {
|
|
res.status(400).json({ error: 'planId inválido' });
|
|
return;
|
|
}
|
|
res.status(500).json({ error: 'Erro ao atualizar plano' });
|
|
}
|
|
});
|
|
// Estender trial de um tenant
|
|
exports.adminRouter.patch('/users/:id/trial', async (req, res) => {
|
|
try {
|
|
const id = req.params['id'];
|
|
const { days } = zod_1.z.object({ days: zod_1.z.number().int().min(1).max(365) }).parse(req.body);
|
|
const user = await prisma_1.prisma.user.update({
|
|
where: { id },
|
|
data: {
|
|
trialEndsAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
|
|
},
|
|
select: { id: true, name: true, trialEndsAt: true },
|
|
});
|
|
res.json(user);
|
|
}
|
|
catch (err) {
|
|
if (err instanceof zod_1.z.ZodError) {
|
|
res.status(400).json({ error: 'days inválido' });
|
|
return;
|
|
}
|
|
res.status(500).json({ error: 'Erro ao estender trial' });
|
|
}
|
|
});
|
|
// Resetar senha de um tenant (admin)
|
|
exports.adminRouter.patch('/users/:id/reset-password', async (req, res) => {
|
|
try {
|
|
const id = req.params['id'];
|
|
const { newPassword } = zod_1.z
|
|
.object({ newPassword: zod_1.z.string().min(8).max(128) })
|
|
.parse(req.body);
|
|
const passwordHash = await bcryptjs_1.default.hash(newPassword, 12);
|
|
await prisma_1.prisma.user.update({ where: { id }, data: { passwordHash } });
|
|
res.json({ message: 'Senha redefinida com sucesso' });
|
|
}
|
|
catch (err) {
|
|
if (err instanceof zod_1.z.ZodError) {
|
|
res.status(400).json({ error: 'newPassword inválido' });
|
|
return;
|
|
}
|
|
res.status(500).json({ error: 'Erro ao redefinir senha' });
|
|
}
|
|
});
|
|
// Obter engine ativa
|
|
exports.adminRouter.get('/engine', async (_req, res) => {
|
|
try {
|
|
const settings = await getSettings();
|
|
const storedEngine = settings.baileysEngine ?? process.env.BAILEYS_ENGINE ?? 'infinite';
|
|
res.json({ engine: storedEngine });
|
|
}
|
|
catch (err) {
|
|
console.error('[Admin] Erro ao obter engine:', err);
|
|
res.status(500).json({ error: 'Erro ao obter a engine ativa' });
|
|
}
|
|
});
|
|
// Altera a engine e reinicia o backend (via PM2 ou Docker)
|
|
exports.adminRouter.post('/engine', async (req, res) => {
|
|
try {
|
|
const { engine } = zod_1.z.object({ engine: zod_1.z.enum(['infinite', 'official']) }).parse(req.body);
|
|
// 1. Persiste nas configurações globais
|
|
const settings = await getSettings();
|
|
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, baileysEngine: engine }, 0);
|
|
// 2. Escreve a alteração no arquivo .env para que persista no próximo boot
|
|
const envPath = path_1.default.resolve('./.env');
|
|
try {
|
|
let content = await promises_1.default.readFile(envPath, 'utf-8');
|
|
if (content.includes('BAILEYS_ENGINE=')) {
|
|
content = content.replace(/BAILEYS_ENGINE=\w+/g, `BAILEYS_ENGINE=${engine}`);
|
|
}
|
|
else {
|
|
content += `\nBAILEYS_ENGINE=${engine}\n`;
|
|
}
|
|
await promises_1.default.writeFile(envPath, content, 'utf-8');
|
|
}
|
|
catch (envErr) {
|
|
console.error('[Admin] Erro ao gravar BAILEYS_ENGINE no .env:', envErr);
|
|
}
|
|
// 3. Responde ao frontend antes de desligar/reiniciar
|
|
res.json({ ok: true, engine, message: 'Engine configurada. Reiniciando o servidor...' });
|
|
// 4. Executa o restart após uma breve janela para dar tempo do client receber a resposta HTTP 200 OK
|
|
setTimeout(() => {
|
|
console.info(`[Admin] Alternando engine para "${engine}". Reiniciando processo...`);
|
|
const { exec } = require('child_process');
|
|
exec('pm2 restart newwhats-backend', (err) => {
|
|
if (err) {
|
|
console.warn('[Admin] PM2 não disponível ou falhou ao reiniciar. Saindo com process.exit(0) para restart automático via Docker...');
|
|
process.exit(0);
|
|
}
|
|
});
|
|
}, 1500);
|
|
}
|
|
catch (err) {
|
|
console.error('[Admin] Erro ao alternar engine:', err);
|
|
if (err instanceof zod_1.z.ZodError) {
|
|
res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' });
|
|
return;
|
|
}
|
|
res.status(500).json({ error: 'Erro ao salvar a engine' });
|
|
}
|
|
});
|
|
// ── Rota para Disparo Manual do Deploy ──
|
|
exports.adminRouter.post('/deploys/trigger', async (_req, res) => {
|
|
try {
|
|
const response = await fetch('http://10.99.0.4:9000/hooks/clube67-deploy-hook', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ trigger: 'admin_panel' })
|
|
});
|
|
if (!response.ok)
|
|
throw new Error(`Status ${response.status}`);
|
|
res.json({ ok: true, message: 'Deploy automático iniciado na VPS 4.' });
|
|
}
|
|
catch (err) {
|
|
console.error('[Admin] Falha ao disparar webhook de deploy:', err.message);
|
|
res.status(502).json({ error: 'Não foi possível se conectar ao servidor de build' });
|
|
}
|
|
});
|
|
//# sourceMappingURL=admin.routes.js.map
|