Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/backend/src/modules/auth/admin.routes.js
T
VPS 4 Deploy Agent 5ec6bd6354
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): migrate clube67 to newwhats.clube67.com directory
2026-05-18 03:26:41 +02:00

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