Files
iistwin/server/routes/admin.routes.ts
Ильяс Султанов 701af06290 feat(billing): PaymentProvider + Mock, self-service пополнение баланса
Шаг 0.13 плана production-готовности:
- интерфейс PaymentProvider (createPayment/verifyWebhook/getPaymentStatus)
- MockPaymentProvider с настоящим webhook (sha256-подпись, идемпотентность)
- applySucceededPayment: транзакция с guard, credit, авто-снятие billingBlocked
- роуты payments + confirm-test (только mock) + публичный webhook
- Billing.tsx: Пополнить/Подтвердить (тест), фикс setLocation в рендере
- миграция 0080 billing_payments, 13 новых тестов (78/78)
2026-09-07 22:45:30 +03:00

334 lines
16 KiB
TypeScript

import { Router } from "express";
import { storage } from "../storage";
import { requireSuperAdmin, authenticateToken, requirePermission, type AuthenticatedRequest, type SuperAdminRequest } from "../middleware/auth.middleware";
import { authLimiter } from "./shared";
const router = Router();
// =====================
// Super Admin Endpoints
// =====================
// GET /api/superadmin/exists — public, returns whether any super admin exists
router.get('/api/superadmin/exists', async (_req, res) => {
try {
const count = await storage.getSuperAdminCount();
return res.json({ exists: count > 0 });
} catch (error) {
console.error('SuperAdmin exists check error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/superadmin/seed — create first super admin (only if none exist)
router.post('/api/superadmin/seed', authLimiter, async (req, res) => {
try {
const { superAdminService } = await import('../services/superadmin.service');
const { email, password, name, seedToken } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ success: false, error: 'Необходимы email, password и name' });
}
// If there are already super admins — always reject
const count = await storage.getSuperAdminCount();
if (count > 0) {
return res.status(409).json({ success: false, error: 'Суперадмин уже существует' });
}
// If SUPERADMIN_SEED_TOKEN is set — require it; otherwise allow without token (first-run UI flow)
const expectedToken = process.env.SUPERADMIN_SEED_TOKEN;
if (expectedToken && seedToken !== expectedToken) {
return res.status(403).json({ success: false, error: 'Неверный seed token' });
}
const result = await superAdminService.seedFirstSuperAdmin(email, password, name);
if (result.success) {
// Auto-login: return a token so the UI can redirect immediately
const loginResult = await superAdminService.login(email, password);
if (loginResult.success) {
return res.status(201).json({ success: true, token: loginResult.token, superAdmin: loginResult.superAdmin });
}
return res.status(201).json({ success: true });
}
return res.status(400).json({ success: false, error: result.error });
} catch (error) {
console.error('SuperAdmin seed error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/superadmin/forgot-password — request password reset link
router.post('/api/superadmin/forgot-password', authLimiter, async (req, res) => {
try {
const { superAdminService } = await import('../services/superadmin.service');
const { email } = req.body;
if (!email) {
return res.status(400).json({ success: false, error: 'Необходим email' });
}
await superAdminService.requestPasswordReset(email);
return res.json({ success: true });
} catch (error) {
console.error('SuperAdmin forgot password error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/superadmin/reset-password — confirm reset with token and new password
router.post('/api/superadmin/reset-password', authLimiter, async (req, res) => {
try {
const { superAdminService } = await import('../services/superadmin.service');
const { token, password } = req.body;
if (!token || !password) {
return res.status(400).json({ success: false, error: 'Необходимы token и password' });
}
if (password.length < 8) {
return res.status(400).json({ success: false, error: 'Пароль должен быть не менее 8 символов' });
}
const result = await superAdminService.confirmPasswordReset(token, password);
if (result.success) {
return res.json({ success: true });
}
return res.status(400).json({ success: false, error: result.error });
} catch (error) {
console.error('SuperAdmin reset password error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/superadmin/login
router.post('/api/superadmin/login', authLimiter, async (req, res) => {
try {
const { superAdminService } = await import('../services/superadmin.service');
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ success: false, error: 'Необходимы email и password' });
}
const result = await superAdminService.login(email, password);
if (result.success) {
return res.json({ success: true, token: result.token, superAdmin: result.superAdmin });
}
return res.status(401).json({ success: false, error: result.error });
} catch (error) {
console.error('SuperAdmin login error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/superadmin/admins — list all super admins
router.get('/api/superadmin/admins', requireSuperAdmin, async (_req, res) => {
try {
const admins = await storage.getAllSuperAdmins();
const safe = admins.map(a => ({ id: a.id, email: a.email, name: a.name, createdAt: a.createdAt, lastLogin: a.lastLogin }));
return res.json({ success: true, admins: safe });
} catch (error) {
console.error('SuperAdmin list error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/superadmin/admins — create additional super admin
router.post('/api/superadmin/admins', requireSuperAdmin, authLimiter, async (req, res) => {
try {
const { superAdminService } = await import('../services/superadmin.service');
const { email, password, name } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ success: false, error: 'Необходимы email, password и name' });
}
if (password.length < 8) {
return res.status(400).json({ success: false, error: 'Пароль должен быть не менее 8 символов' });
}
const result = await superAdminService.createAdditionalSuperAdmin(email, password, name);
if (result.success) {
return res.status(201).json({ success: true, superAdmin: result.superAdmin });
}
return res.status(400).json({ success: false, error: result.error });
} catch (error) {
console.error('SuperAdmin create error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// DELETE /api/superadmin/admins/:id — delete super admin
router.delete('/api/superadmin/admins/:id', requireSuperAdmin, async (req: SuperAdminRequest, res) => {
try {
const { superAdminService } = await import('../services/superadmin.service');
const idToDelete = parseInt(req.params.id);
if (isNaN(idToDelete)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
const currentId = req.superAdmin?.id ?? 0;
const result = await superAdminService.deleteSuperAdmin(idToDelete, currentId);
if (result.success) {
return res.json({ success: true });
}
return res.status(400).json({ success: false, error: result.error });
} catch (error) {
console.error('SuperAdmin delete error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/superadmin/organizations — list all with stats
// requireSuperAdmin already sets up the SA db context (app.is_superadmin='true')
// via AsyncLocalStorage, so storage.getAllOrganizationsWithStats() automatically
// bypasses tenant RLS policies without any additional wrapping here.
router.get('/api/superadmin/organizations', requireSuperAdmin, async (_req, res) => {
try {
const orgs = await storage.getAllOrganizationsWithStats();
return res.json({ success: true, organizations: orgs });
} catch (error) {
console.error('SuperAdmin orgs error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/superadmin/organizations/:id/stats
router.get('/api/superadmin/organizations/:id/stats', requireSuperAdmin, async (req, res) => {
try {
const orgId = parseInt(req.params.id);
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
const [org, stats] = await Promise.all([
storage.getOrganization(orgId),
storage.getOrganizationDetailedStats(orgId),
]);
if (!org) return res.status(404).json({ success: false, error: 'Организация не найдена' });
return res.json({ success: true, organization: org, stats });
} catch (error) {
console.error('SuperAdmin org stats error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// PATCH /api/superadmin/organizations/:id — update isActive etc
router.patch('/api/superadmin/organizations/:id', requireSuperAdmin, async (req, res) => {
try {
const orgId = parseInt(req.params.id);
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
const { isActive } = req.body;
if (typeof isActive !== 'boolean') {
return res.status(400).json({ success: false, error: 'Поле isActive должно быть булевым значением' });
}
const updates: { isActive: boolean } = { isActive };
const org = await storage.updateOrganization(orgId, updates);
if (!org) return res.status(404).json({ success: false, error: 'Организация не найдена' });
return res.json({ success: true, organization: org });
} catch (error) {
console.error('SuperAdmin update org error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// =====================
// Billing — Super Admin
// =====================
// GET /api/superadmin/organizations/:id/billing
router.get('/api/superadmin/organizations/:id/billing', requireSuperAdmin, async (req, res) => {
try {
const orgId = parseInt(req.params.id);
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
const org = await storage.getOrganization(orgId);
if (!org) return res.status(404).json({ success: false, error: 'Организация не найдена' });
const billing = await storage.getOrCreateBillingSettings(orgId);
return res.json({ success: true, billing, organization: org });
} catch (error) {
console.error('SuperAdmin billing get error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/superadmin/organizations/:id/billing/topup — пополнить баланс
router.post('/api/superadmin/organizations/:id/billing/topup', requireSuperAdmin, async (req: SuperAdminRequest, res) => {
try {
const orgId = parseInt(req.params.id);
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
const { amount, description } = req.body as { amount: unknown; description?: string };
const amountNum = parseFloat(String(amount));
if (!amount || isNaN(amountNum) || amountNum <= 0) {
return res.status(400).json({ success: false, error: 'Сумма должна быть положительным числом' });
}
const tx = await storage.addBillingCredit(orgId, amountNum, description || 'Пополнение баланса', req.superAdmin?.id);
return res.json({ success: true, transaction: tx });
} catch (error) {
console.error('SuperAdmin billing topup error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// PATCH /api/superadmin/organizations/:id/billing — изменить настройки (pricePerUser, currency)
router.patch('/api/superadmin/organizations/:id/billing', requireSuperAdmin, async (req, res) => {
try {
const orgId = parseInt(req.params.id);
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
const { pricePerUser, currency } = req.body as { pricePerUser?: unknown; currency?: unknown };
const updates: { pricePerUser?: string; currency?: string } = {};
if (pricePerUser !== undefined) {
const p = parseFloat(String(pricePerUser));
if (isNaN(p) || p < 0) return res.status(400).json({ success: false, error: 'Некорректная цена' });
updates.pricePerUser = p.toFixed(2);
}
if (currency) updates.currency = String(currency).slice(0, 10);
const billing = await storage.updateBillingSettings(orgId, updates);
return res.json({ success: true, billing });
} catch (error) {
console.error('SuperAdmin billing update error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/superadmin/organizations/:id/billing/unblock — снять блокировку
router.post('/api/superadmin/organizations/:id/billing/unblock', requireSuperAdmin, async (req, res) => {
try {
const orgId = parseInt(req.params.id);
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
await storage.setBillingBlocked(orgId, false);
return res.json({ success: true });
} catch (error) {
console.error('SuperAdmin billing unblock error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/superadmin/organizations/:id/billing/transactions
router.get('/api/superadmin/organizations/:id/billing/transactions', requireSuperAdmin, async (req, res) => {
try {
const orgId = parseInt(req.params.id);
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
const transactions = await storage.getBillingTransactions(orgId, 50);
return res.json({ success: true, transactions });
} catch (error) {
console.error('SuperAdmin billing transactions error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// =====================
// Billing — Org Admin
// =====================
// GET /api/billing/summary — текущий баланс, активные пользователи и статус
router.get('/api/billing/summary', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => {
try {
const summary = await storage.getBillingSummary(req.organizationId!);
const { getPaymentProviderName } = await import('../billing/payment-provider');
return res.json({ success: true, summary: { ...summary, paymentProvider: getPaymentProviderName() } });
} catch (error) {
console.error('Billing summary error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/billing/transactions — история транзакций
router.get('/api/billing/transactions', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => {
try {
const transactions = await storage.getBillingTransactions(req.organizationId!, 20);
return res.json({ success: true, transactions });
} catch (error) {
console.error('Billing transactions error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
export default router;