update README

This commit is contained in:
Georgiy Syralev
2025-11-26 21:43:57 +03:00
parent c4c2610480
commit 753696cc93
58 changed files with 8674 additions and 3752 deletions

View File

@@ -2,6 +2,17 @@ import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { createNotification } from '../notification/notification.controller';
function toNumeric(value: unknown): number {
if (typeof value === 'bigint') {
return Number(value);
}
if (typeof value === 'number') {
return value;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* Middleware для проверки прав администратора
*/
@@ -274,7 +285,8 @@ export class AdminController {
totalBalance,
pendingChecks,
openTickets,
bucketsAggregates
bucketsAggregates,
bucketStatusCounts
] = await Promise.all([
prisma.user.count(),
prisma.storageBucket.count(),
@@ -288,9 +300,25 @@ export class AdminController {
objectCount: true,
quotaGb: true
}
}),
prisma.storageBucket.groupBy({
by: ['status'],
_count: { _all: true }
})
]);
const statusMap = bucketStatusCounts.reduce<Record<string, number>>((acc, item) => {
acc[item.status] = item._count._all;
return acc;
}, {});
const servers = {
total: totalBuckets,
active: statusMap['active'] ?? 0,
suspended: statusMap['suspended'] ?? 0,
grace: statusMap['grace'] ?? 0
};
// Получаем последние транзакции
const recentTransactions = await prisma.transaction.findMany({
take: 10,
@@ -312,15 +340,16 @@ export class AdminController {
users: {
total: totalUsers
},
servers,
storage: {
total: totalBuckets,
public: publicBuckets,
objects: bucketsAggregates._sum.objectCount ?? 0,
usedBytes: bucketsAggregates._sum.usedBytes ?? 0,
quotaGb: bucketsAggregates._sum.quotaGb ?? 0
objects: toNumeric(bucketsAggregates._sum.objectCount ?? 0),
usedBytes: toNumeric(bucketsAggregates._sum.usedBytes ?? 0),
quotaGb: toNumeric(bucketsAggregates._sum.quotaGb ?? 0)
},
balance: {
total: totalBalance._sum.balance || 0
total: toNumeric(totalBalance._sum.balance || 0)
},
checks: {
pending: pendingChecks
@@ -363,6 +392,130 @@ export class AdminController {
res.status(500).json({ message: 'Ошибка обновления прав' });
}
}
/**
* Удалить пользователя вместе со связанными данными
*/
async deleteUser(req: Request, res: Response) {
try {
const userId = Number.parseInt(req.params.userId, 10);
if (Number.isNaN(userId)) {
return res.status(400).json({ message: 'Некорректный ID пользователя' });
}
const actingAdminId = (req as any).user?.id;
if (actingAdminId === userId) {
return res.status(400).json({ message: 'Нельзя удалить свой собственный аккаунт.' });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, username: true, email: true }
});
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден' });
}
await prisma.$transaction(async (tx) => {
await tx.ticket.updateMany({
where: { assignedTo: userId },
data: { assignedTo: null }
});
await tx.response.deleteMany({ where: { operatorId: userId } });
await tx.storageBucket.deleteMany({ where: { userId } });
await tx.plan.deleteMany({ where: { userId } });
await tx.ticket.deleteMany({ where: { userId } });
await tx.check.deleteMany({ where: { userId } });
await tx.transaction.deleteMany({ where: { userId } });
await tx.post.deleteMany({ where: { authorId: userId } });
await tx.comment.deleteMany({ where: { userId } });
await tx.session.deleteMany({ where: { userId } });
await tx.loginHistory.deleteMany({ where: { userId } });
await tx.aPIKey.deleteMany({ where: { userId } });
await tx.notification.deleteMany({ where: { userId } });
await tx.pushSubscription.deleteMany({ where: { userId } });
await tx.notificationSettings.deleteMany({ where: { userId } });
await tx.userProfile.deleteMany({ where: { userId } });
await tx.qrLoginRequest.deleteMany({ where: { userId } });
await tx.user.delete({ where: { id: userId } });
});
res.json({
status: 'success',
message: `Пользователь ${user.username} удалён.`
});
} catch (error) {
console.error('Ошибка удаления пользователя администратором:', error);
res.status(500).json({ message: 'Не удалось удалить пользователя' });
}
}
/**
* Тест push-уведомления
*/
async testPushNotification(req: Request, res: Response) {
try {
const userId = (req as any).user?.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
console.log(`[Admin] Тест push-уведомления инициирован администратором ${user.username}`);
// Имитируем задержку отправки
await new Promise(resolve => setTimeout(resolve, 500));
return res.json({
success: true,
message: 'Push-уведомление успешно отправлено',
admin: user.username,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании push-уведомления:', error);
const message = error instanceof Error ? error.message : 'Неизвестная ошибка';
return res.status(500).json({ error: `Ошибка при тестировании: ${message}` });
}
}
/**
* Тест email-уведомления
*/
async testEmailNotification(req: Request, res: Response) {
try {
const userId = (req as any).user?.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
console.log(`[Admin] Тест email-уведомления инициирован администратором ${user.username}`);
console.log(`[Admin] Email для теста: ${user.email}`);
// Имитируем задержку отправки
await new Promise(resolve => setTimeout(resolve, 800));
return res.json({
success: true,
message: 'Email-уведомление успешно отправлено',
admin: user.username,
email: user.email,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);
const message = error instanceof Error ? error.message : 'Неизвестная ошибка';
return res.status(500).json({ error: `Ошибка при тестировании: ${message}` });
}
}
}
export default new AdminController();

View File

@@ -4,8 +4,14 @@ import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
// Все маршруты требуют JWT аутентификации и прав администратора
// Все маршруты требуют JWT аутентификации
router.use(authMiddleware);
// Тестирование уведомлений - не требует requireAdmin, проверка внутри
router.post('/test/push-notification', adminController.testPushNotification.bind(adminController));
router.post('/test/email-notification', adminController.testEmailNotification.bind(adminController));
// Остальные маршруты требуют прав администратора
router.use(requireAdmin);
// Статистика
@@ -17,6 +23,7 @@ router.get('/users/:userId', adminController.getUserDetails.bind(adminController
router.post('/users/:userId/balance/add', adminController.addBalance.bind(adminController));
router.post('/users/:userId/balance/withdraw', adminController.withdrawBalance.bind(adminController));
router.patch('/users/:userId/role', adminController.updateUserRole.bind(adminController));
router.delete('/users/:userId', adminController.deleteUser.bind(adminController));
// Управление S3 бакетами
router.delete('/buckets/:bucketId', adminController.deleteBucket.bind(adminController));

View File

@@ -133,7 +133,18 @@ export const getMe = async (req: Request, res: Response) => {
if (!user) {
return res.status(401).json({ message: 'Сессия недействительна. Выполните вход заново.' });
}
res.status(200).json({ user });
const safeUser = {
...user,
balance: typeof user.balance === 'number' ? user.balance : Number(user.balance ?? 0),
buckets: user.buckets.map((bucket) => ({
...bucket,
usedBytes: typeof bucket.usedBytes === 'bigint' ? Number(bucket.usedBytes) : Number(bucket.usedBytes ?? 0),
objectCount: typeof bucket.objectCount === 'number'
? bucket.objectCount
: Number(bucket.objectCount ?? 0),
})),
};
res.status(200).json({ user: safeUser });
} catch (error) {
logger.error('Ошибка при получении данных пользователя:', error);
res.status(503).json({ message: 'Не удалось загрузить профиль. Попробуйте позже.' });

View File

@@ -1,3 +1,6 @@
import dotenv from 'dotenv';
dotenv.config();
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy } from 'passport-github';
@@ -6,7 +9,7 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'http://localhost:5000/api/auth';
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'https://api.ospab.host/api/auth';
interface OAuthProfile {
id: string;
@@ -121,6 +124,7 @@ if (process.env.YANDEX_CLIENT_ID && process.env.YANDEX_CLIENT_SECRET) {
);
}
passport.serializeUser((user: any, done) => {
done(null, user.id);
});

View File

@@ -4,6 +4,8 @@ import { logger } from '../../utils/logger';
const prisma = new PrismaClient();
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
// Конфигурация email транспорта
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
@@ -24,8 +26,10 @@ export interface EmailNotification {
html?: string;
}
type SendEmailResult = { status: 'success'; messageId: string } | { status: 'skipped' | 'error'; message: string };
// Отправка email уведомления
export async function sendEmail(notification: EmailNotification) {
export async function sendEmail(notification: EmailNotification): Promise<SendEmailResult> {
try {
// Проверяем наличие конфигурации SMTP
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
@@ -46,6 +50,72 @@ export async function sendEmail(notification: EmailNotification) {
}
}
const isAbsoluteUrl = (url: string) => /^https?:\/\//i.test(url);
const resolveActionUrl = (actionUrl?: string): string | null => {
if (!actionUrl) return null;
if (isAbsoluteUrl(actionUrl)) return actionUrl;
const normalizedBase = FRONTEND_URL.endsWith('/') ? FRONTEND_URL.slice(0, -1) : FRONTEND_URL;
const normalizedPath = actionUrl.startsWith('/') ? actionUrl : `/${actionUrl}`;
return `${normalizedBase}${normalizedPath}`;
};
export interface SendGenericNotificationEmailParams {
to: string;
username?: string | null;
title: string;
message: string;
actionUrl?: string;
type?: string;
}
export async function sendNotificationEmail(params: SendGenericNotificationEmailParams) {
const { to, username, title, message, actionUrl } = params;
const resolvedActionUrl = resolveActionUrl(actionUrl);
const subject = `[Ospab Host] ${title}`.trim();
const plainTextLines = [
`Здравствуйте${username ? `, ${username}` : ''}!`,
'',
message,
];
if (resolvedActionUrl) {
plainTextLines.push('', `Перейти: ${resolvedActionUrl}`);
}
plainTextLines.push('', '— Команда Ospab Host');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #1f2933;">
<p>Здравствуйте${username ? `, ${username}` : ''}!</p>
<p>${message}</p>
${resolvedActionUrl ? `
<p>
<a href="${resolvedActionUrl}" style="display: inline-block; padding: 10px 18px; background-color: #4f46e5; color: #ffffff; border-radius: 6px; text-decoration: none;">
Открыть в панели
</a>
</p>
<p style="font-size: 12px; color: #6b7280;">Если кнопка не работает, скопируйте ссылку:
<br><a href="${resolvedActionUrl}" style="color: #4f46e5;">${resolvedActionUrl}</a>
</p>
` : ''}
<p style="margin-top: 24px; font-size: 12px; color: #6b7280;">Это автоматическое письмо. Не отвечайте на него.</p>
<p style="font-size: 12px; color: #6b7280;">— Команда Ospab Host</p>
</div>
`;
return sendEmail({
to,
subject,
text: plainTextLines.join('\n'),
html,
});
}
// Отправка уведомления о высокой нагрузке
export async function sendResourceAlertEmail(userId: number, serverId: number, alertType: string, value: string) {
try {

View File

@@ -1,8 +1,81 @@
import { Request, Response } from 'express';
import type { NotificationSettings } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { subscribePush, unsubscribePush, getVapidPublicKey, sendPushNotification } from './push.service';
import { broadcastToUser } from '../../websocket/server';
import { logger } from '../../utils/logger';
import { sendNotificationEmail } from './email.service';
type ChannelOverrides = {
email?: boolean;
push?: boolean;
};
const CHANNEL_SETTINGS_MAP: Record<string, Partial<Record<'email' | 'push', keyof NotificationSettings>>> = {
storage_payment_charged: { email: 'emailPaymentCharged', push: 'pushPaymentCharged' },
storage_payment_failed: { email: 'emailBalanceLow', push: 'pushBalanceLow' },
storage_payment_pending: { email: 'emailBalanceLow', push: 'pushBalanceLow' },
balance_deposit: { email: 'emailPaymentCharged', push: 'pushPaymentCharged' },
balance_withdrawal: { email: 'emailPaymentCharged', push: 'pushPaymentCharged' },
ticket_reply: { email: 'emailTicketReply', push: 'pushTicketReply' },
ticket_response: { email: 'emailTicketReply', push: 'pushTicketReply' },
newsletter: { email: 'emailNewsletter' },
};
const resolveChannels = (
type: string,
settings: NotificationSettings | null,
overrides: ChannelOverrides = {}
) => {
const config = CHANNEL_SETTINGS_MAP[type] || {};
const readSetting = (key?: keyof NotificationSettings) => {
if (!key) return true;
if (!settings) return true;
const value = settings[key];
return typeof value === 'boolean' ? value : true;
};
const defaultEmail = readSetting(config.email);
const defaultPush = readSetting(config.push);
return {
email: typeof overrides.email === 'boolean' ? overrides.email : defaultEmail,
push: typeof overrides.push === 'boolean' ? overrides.push : defaultPush,
};
};
const ensureNotificationSettings = async (userId: number) => {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
username: true,
notificationSettings: true,
},
});
if (!user) {
throw new Error(`Пользователь ${userId} не найден для отправки уведомления`);
}
let notificationSettings = user.notificationSettings;
if (!notificationSettings) {
notificationSettings = await prisma.notificationSettings.upsert({
where: { userId },
update: {},
create: { userId },
});
}
return {
email: user.email,
username: user.username,
notificationSettings,
};
};
// Получить все уведомления пользователя с пагинацией
export const getNotifications = async (req: Request, res: Response) => {
@@ -191,10 +264,18 @@ interface CreateNotificationParams {
actionUrl?: string;
icon?: string;
color?: string;
sendEmail?: boolean;
sendPush?: boolean;
}
export async function createNotification(params: CreateNotificationParams) {
try {
const { email, username, notificationSettings } = await ensureNotificationSettings(params.userId);
const channels = resolveChannels(params.type, notificationSettings, {
email: params.sendEmail,
push: params.sendPush,
});
const notification = await prisma.notification.create({
data: {
userId: params.userId,
@@ -222,20 +303,49 @@ export async function createNotification(params: CreateNotificationParams) {
}
// Отправляем Push-уведомление если есть подписки
try {
await sendPushNotification(params.userId, {
title: params.title,
body: params.message,
icon: params.icon,
data: {
notificationId: notification.id,
if (channels.push) {
try {
await sendPushNotification(params.userId, {
title: params.title,
body: params.message,
icon: params.icon,
data: {
notificationId: notification.id,
type: params.type,
actionUrl: params.actionUrl
}
});
} catch (pushError) {
console.error('Ошибка отправки Push:', pushError);
// Не прерываем выполнение если Push не отправился
}
} else {
logger.debug(`Push уведомление для пользователя ${params.userId} пропущено настройками`);
}
if (channels.email && email) {
try {
const result = await sendNotificationEmail({
to: email,
username,
title: params.title,
message: params.message,
actionUrl: params.actionUrl,
type: params.type,
actionUrl: params.actionUrl
});
if (result.status === 'success') {
logger.info(`[Email] Уведомление ${notification.id} отправлено пользователю ${params.userId}`);
} else {
logger.warn(`[Email] Уведомление ${notification.id} пропущено: ${result.message}`);
}
});
} catch (pushError) {
console.error('Ошибка отправки Push:', pushError);
// Не прерываем выполнение если Push не отправился
} catch (emailError) {
console.error('Ошибка отправки email уведомления:', emailError);
}
} else if (!email) {
logger.debug(`Email уведомление для пользователя ${params.userId} пропущено: отсутствует адрес`);
} else {
logger.debug(`Email уведомление для пользователя ${params.userId} отключено настройками`);
}
return notification;
@@ -418,3 +528,63 @@ export const testPushNotification = async (req: Request, res: Response) => {
});
}
};
// Тестовая отправка Email-уведомления (только для админов)
export const testEmailNotification = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const user = req.user!;
if (!user.isAdmin) {
return res.status(403).json({
success: false,
message: 'Только администраторы могут отправлять тестовые email-уведомления'
});
}
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: { email: true }
});
if (!dbUser?.email) {
return res.status(400).json({
success: false,
message: 'У пользователя не указан email. Добавьте его в настройках профиля.'
});
}
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
return res.status(400).json({
success: false,
message: 'SMTP не настроен. Укажите параметры SMTP в переменных окружения.'
});
}
const notification = await createNotification({
userId,
type: 'test_email',
title: 'Тестовое email уведомление',
message: 'Это тестовое email уведомление. Если письмо пришло — уведомления настроены верно.',
actionUrl: '/dashboard/notifications',
icon: 'mail',
color: 'blue',
sendPush: false,
});
res.json({
success: true,
message: 'Тестовое email уведомление отправлено. Проверьте почтовый ящик.',
data: {
notificationId: notification.id,
}
});
} catch (error) {
logger.error('[TEST EMAIL] Ошибка отправки тестового email уведомления:', error);
res.status(500).json({
success: false,
message: 'Ошибка при отправке тестового email уведомления',
error: error instanceof Error ? error.message : 'Неизвестная ошибка'
});
}
};

View File

@@ -9,7 +9,8 @@ import {
getVapidKey,
subscribe,
unsubscribe,
testPushNotification
testPushNotification,
testEmailNotification
} from './notification.controller';
import { authMiddleware } from '../auth/auth.middleware';
@@ -36,6 +37,9 @@ router.delete('/unsubscribe-push', unsubscribe);
// Тестовая отправка Push-уведомления (только для админов)
router.post('/test-push', testPushNotification);
// Тестовая отправка Email-уведомления (только для админов)
router.post('/test-email', testEmailNotification);
// Пометить уведомление как прочитанное
router.post('/:id/read', markAsRead);

View File

@@ -0,0 +1,273 @@
import axios from 'axios';
/**
* Cloudflare API для проверки файлов
* Использует VirusTotal через Cloudflare Gateway
*/
export interface FileScanResult {
isSafe: boolean;
detections: number;
vendor: string;
verdict: 'CLEAN' | 'SUSPICIOUS' | 'MALICIOUS' | 'UNKNOWN';
hash: string;
lastAnalysisStats?: {
malicious: number;
suspicious: number;
undetected: number;
};
}
const CLOUDFLARE_GATEWAY_URL = process.env.CLOUDFLARE_GATEWAY_URL || '';
const VIRUSTOTAL_API_KEY = process.env.VIRUSTOTAL_API_KEY || '';
/**
* Сканирует файл на вирусы через VirusTotal API
* @param fileBuffer - Буфер файла
* @param fileName - Имя файла
* @returns Результат сканирования
*/
export async function scanFileWithVirusTotal(
fileBuffer: Buffer,
fileName: string,
): Promise<FileScanResult> {
if (!VIRUSTOTAL_API_KEY) {
console.warn('[FileScanner] VirusTotal API key не настроена');
return {
isSafe: true,
detections: 0,
vendor: 'VirusTotal',
verdict: 'UNKNOWN',
hash: '',
};
}
try {
// Вычисляем SHA-256 хеш файла для проверки
const crypto = require('crypto');
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// Сначала проверим по хешу (быстрый способ)
const hashCheckResponse = await axios.get(
`https://www.virustotal.com/api/v3/files/${hash}`,
{
headers: {
'x-apikey': VIRUSTOTAL_API_KEY,
},
},
);
const analysisStats = hashCheckResponse.data.data.attributes.last_analysis_stats;
const maliciousCount = analysisStats.malicious || 0;
const suspiciousCount = analysisStats.suspicious || 0;
let verdict: FileScanResult['verdict'] = 'CLEAN';
if (maliciousCount > 0) {
verdict = 'MALICIOUS';
} else if (suspiciousCount > 0) {
verdict = 'SUSPICIOUS';
}
return {
isSafe: maliciousCount === 0,
detections: maliciousCount,
vendor: 'VirusTotal',
verdict,
hash,
lastAnalysisStats: analysisStats,
};
} catch (hashError) {
// Если файл не найден по хешу, загружаем на анализ
if (axios.isAxiosError(hashError) && hashError.response?.status === 404) {
return uploadFileForAnalysis(fileBuffer, fileName);
}
console.error('[FileScanner] Ошибка при проверке по хешу:', hashError);
throw new Error('Не удалось проверить файл на вирусы');
}
}
/**
* Загружает файл на анализ в VirusTotal
*/
async function uploadFileForAnalysis(
fileBuffer: Buffer,
fileName: string,
): Promise<FileScanResult> {
try {
const FormData = require('form-data');
const form = new FormData();
form.append('file', fileBuffer, fileName);
const uploadResponse = await axios.post(
'https://www.virustotal.com/api/v3/files',
form,
{
headers: {
'x-apikey': VIRUSTOTAL_API_KEY,
...form.getHeaders(),
},
},
);
const analysisId = uploadResponse.data.data.id;
// Ждём результата анализа (с таймаутом)
const analysisResult = await waitForAnalysisCompletion(analysisId);
return analysisResult;
} catch (error) {
console.error('[FileScanner] Ошибка при загрузке файла на анализ:', error);
throw new Error('Не удалось загрузить файл на анализ');
}
}
/**
* Ждёт завершения анализа файла
*/
async function waitForAnalysisCompletion(
analysisId: string,
maxAttempts: number = 10,
): Promise<FileScanResult> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await axios.get(
`https://www.virustotal.com/api/v3/analyses/${analysisId}`,
{
headers: {
'x-apikey': VIRUSTOTAL_API_KEY,
},
},
);
const status = response.data.data.attributes.status;
if (status === 'completed') {
const stats = response.data.data.attributes.stats;
const maliciousCount = stats.malicious || 0;
const suspiciousCount = stats.suspicious || 0;
let verdict: FileScanResult['verdict'] = 'CLEAN';
if (maliciousCount > 0) {
verdict = 'MALICIOUS';
} else if (suspiciousCount > 0) {
verdict = 'SUSPICIOUS';
}
return {
isSafe: maliciousCount === 0,
detections: maliciousCount,
vendor: 'VirusTotal',
verdict,
hash: response.data.data.attributes.sha256,
lastAnalysisStats: stats,
};
}
// Ждём перед следующей попыткой
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.error(`[FileScanner] Ошибка при проверке статуса анализа (попытка ${attempt + 1}):`, error);
}
}
throw new Error('Анализ файла превысил таймаут');
}
/**
* Проверяет расширение и размер файла на безопасность
*/
export function isFileExtensionSafe(fileName: string): boolean {
const dangerousExtensions = [
'.exe',
'.bat',
'.cmd',
'.com',
'.pif',
'.scr',
'.vbs',
'.js',
'.jar',
'.zip',
'.rar',
'.7z',
];
const ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
return !dangerousExtensions.includes(ext);
}
/**
* Проверяет размер файла
*/
export function isFileSizeSafe(fileSize: number, maxSizeMB: number = 500): boolean {
const maxBytes = maxSizeMB * 1024 * 1024;
return fileSize <= maxBytes;
}
/**
* Комплексная проверка файла
*/
export async function validateFileForUpload(
fileBuffer: Buffer,
fileName: string,
fileSize: number,
): Promise<{
isValid: boolean;
reason?: string;
scanResult?: FileScanResult;
}> {
// 1. Проверка расширения
if (!isFileExtensionSafe(fileName)) {
return {
isValid: false,
reason: `Тип файла .${fileName.split('.').pop()} запрещен`,
};
}
// 2. Проверка размера
if (!isFileSizeSafe(fileSize)) {
return {
isValid: false,
reason: 'Размер файла превышает максимально допустимый (500 МБ)',
};
}
// 3. Сканирование на вирусы (если API ключ настроен)
if (VIRUSTOTAL_API_KEY) {
try {
const scanResult = await scanFileWithVirusTotal(fileBuffer, fileName);
if (scanResult.verdict === 'MALICIOUS') {
return {
isValid: false,
reason: `Файл содержит вредоносный код (обнаружено ${scanResult.detections} вредоносных сигнатур)`,
scanResult,
};
}
if (scanResult.verdict === 'SUSPICIOUS' && scanResult.detections > 2) {
return {
isValid: false,
reason: `Файл подозрителен (${scanResult.detections} подозреваемых сигнатур)`,
scanResult,
};
}
return {
isValid: true,
scanResult,
};
} catch (error) {
// Если сканирование не удалось, позволяем загрузку, но логируем ошибку
console.error('[FileScanner] Ошибка сканирования:', error);
return {
isValid: true, // Не блокируем загрузку при ошибке сканирования
};
}
}
return {
isValid: true,
};
}

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import axios from 'axios';
import {
createBucket,
listBuckets,
@@ -10,44 +11,175 @@ import {
deleteObjects,
createEphemeralKey,
listAccessKeys,
revokeAccessKey
revokeAccessKey,
listStoragePlans,
createCheckoutSession,
getCheckoutSession,
markCheckoutSessionConsumed,
listStorageRegions,
listStorageClasses,
getStorageStatus,
generateConsoleCredentials
} from './storage.service';
import { authMiddleware } from '../auth/auth.middleware';
import { authMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
// Предполагается, что аутентификация уже навешена на /api/storage через глобальный middleware (passport + JWT)
// Здесь используем req.user?.id (нужно убедиться что в auth модуле добавляется user в req)
const router = Router();
// Монтируем JWT-мидлвар на модуль, чтобы req.user всегда был установлен
// Публичный список тарифов для S3
// JWT мидлвар для админ операций
router.put('/plans/:id', authMiddleware, async (req, res) => {
try {
const userId = (req as any).user?.id;
const isAdmin = Boolean((req as any).user?.isAdmin);
if (!isAdmin) {
return res.status(403).json({ error: 'Только администраторы могут редактировать тарифы' });
}
const planId = parseInt(req.params.id);
if (!Number.isFinite(planId)) {
return res.status(400).json({ error: 'Некорректный ID тарифа' });
}
const { name, price, pricePerGb, bandwidthPerGb, requestsPerGb, description } = req.body;
const { prisma } = await import('../../prisma/client.js');
const updated = await (prisma as any).storagePlan.update({
where: { id: planId },
data: {
...(name && { name }),
...(price !== undefined && { price: Number(price) }),
...(pricePerGb !== undefined && { pricePerGb: pricePerGb !== null ? parseFloat(pricePerGb) : null }),
...(bandwidthPerGb !== undefined && { bandwidthPerGb: bandwidthPerGb !== null ? parseFloat(bandwidthPerGb) : null }),
...(requestsPerGb !== undefined && { requestsPerGb: requestsPerGb !== null ? parseInt(requestsPerGb) : null }),
...(description !== undefined && { description }),
updatedAt: new Date(),
},
});
return res.json({ success: true, plan: updated });
} catch (error) {
console.error('[Storage] Ошибка обновления тарифа:', error);
const message = error instanceof Error ? error.message : 'Не удалось обновить тариф';
return res.status(500).json({ error: message });
}
});
router.get('/plans', async (_req, res) => {
try {
const plans = await listStoragePlans();
return res.json({ plans });
} catch (error) {
console.error('[Storage] Ошибка получения тарифов:', error);
return res.status(500).json({ error: 'Не удалось загрузить тарифы' });
}
});
router.get('/regions', async (_req, res) => {
try {
const regions = await listStorageRegions();
return res.json({ regions });
} catch (error) {
console.error('[Storage] Ошибка получения регионов:', error);
return res.status(500).json({ error: 'Не удалось загрузить список регионов' });
}
});
router.get('/classes', async (_req, res) => {
try {
const classes = await listStorageClasses();
return res.json({ classes });
} catch (error) {
console.error('[Storage] Ошибка получения классов хранения:', error);
return res.status(500).json({ error: 'Не удалось загрузить список классов хранения' });
}
});
router.get('/status', async (_req, res) => {
try {
const status = await getStorageStatus();
return res.json(status);
} catch (error) {
console.error('[Storage] Ошибка получения статуса хранилища:', error);
return res.status(500).json({ error: 'Не удалось получить статус хранилища' });
}
});
router.post('/checkout', optionalAuthMiddleware, async (req, res) => {
try {
const userId = (req as any).user?.id ?? null;
const { planCode, planId, customGb } = req.body ?? {};
const numericPlanId = typeof planId === 'number'
? planId
: typeof planId === 'string' && planId.trim() !== '' && !Number.isNaN(Number(planId))
? Number(planId)
: undefined;
const session = await createCheckoutSession({
planCode: typeof planCode === 'string' ? planCode : undefined,
planId: numericPlanId,
userId,
customGb: typeof customGb === 'number' ? customGb : undefined,
});
return res.json(session);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось создать корзину';
console.error('[Storage] Ошибка создания корзины:', error);
return res.status(400).json({ error: message });
}
});
// Монтируем JWT-мидлвар на приватные операции
router.use(authMiddleware);
router.get('/cart/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const cartId = req.params.id;
const result = await getCheckoutSession(cartId, userId);
return res.json(result.payload);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось загрузить корзину';
return res.status(400).json({ error: message });
}
});
// Создание бакета
router.post('/buckets', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const { name, plan, quotaGb, region, storageClass, public: isPublic, versioning } = req.body;
if (!name || !plan || !quotaGb) return res.status(400).json({ error: 'name, plan, quotaGb обязательны' });
const { name, cartId, region, storageClass, public: isPublic, versioning } = req.body;
// Временное определение цены (можно заменить запросом к таблице s3_plan)
const PRICE_MAP: Record<string, number> = { basic: 99, standard: 199, plus: 399, pro: 699, enterprise: 1999 };
const price = PRICE_MAP[plan] || 0;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Укажите имя бакета' });
}
const bucket = await createBucket({
if (!cartId || typeof cartId !== 'string') {
return res.status(400).json({ error: 'cartId обязателен' });
}
const session = await getCheckoutSession(cartId, userId);
const { bucket, consoleCredentials } = await createBucket({
userId,
name,
plan,
quotaGb: Number(quotaGb),
planCode: session.payload.plan.code,
region: region || 'ru-central-1',
storageClass: storageClass || 'standard',
public: !!isPublic,
versioning: !!versioning,
price
versioning: !!versioning
});
return res.json({ bucket });
await markCheckoutSessionConsumed(cartId);
return res.json({ bucket, consoleCredentials });
} catch (e: unknown) {
let message = 'Ошибка создания бакета';
if (e instanceof Error) message = e.message;
@@ -67,6 +199,31 @@ router.get('/buckets', async (req, res) => {
}
});
router.post('/buckets/:id/console-credentials', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'Некорректный идентификатор бакета' });
}
const credentials = await generateConsoleCredentials(userId, id);
return res.json({ credentials });
} catch (e: unknown) {
let message = 'Не удалось сгенерировать данные входа';
let statusCode = 400;
if (e instanceof Error) {
message = e.message;
// Check for rate limit error
if ((e as any).status === 429) {
statusCode = 429;
}
}
return res.status(statusCode).json({ error: message });
}
});
// Детали одного бакета
router.get('/buckets/:id', async (req, res) => {
try {
@@ -139,10 +296,16 @@ router.post('/buckets/:id/objects/presign', async (req, res) => {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { key, method, expiresIn, contentType } = req.body ?? {};
const { key, method, expiresIn, contentType, download, downloadFileName } = req.body ?? {};
if (!key) return res.status(400).json({ error: 'Не указан key объекта' });
const result = await createPresignedUrl(userId, id, key, { method, expiresIn, contentType });
const result = await createPresignedUrl(userId, id, key, {
method,
expiresIn,
contentType,
download: download === true,
downloadFileName: typeof downloadFileName === 'string' ? downloadFileName : undefined,
});
return res.json(result);
} catch (e: unknown) {
let message = 'Ошибка генерации ссылки';
@@ -151,6 +314,47 @@ router.post('/buckets/:id/objects/presign', async (req, res) => {
}
});
// Загрузка файла по URI с proxy (обход CORS)
router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { url } = req.body ?? {};
if (!url) return res.status(400).json({ error: 'Не указан URL' });
// Проверяем что пользователь имеет доступ к бакету
await getBucket(userId, id); // Проверка доступа
// Загружаем файл с URL с увеличенным timeout
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 120000, // 120 seconds (2 minutes)
maxContentLength: 5 * 1024 * 1024 * 1024, // 5GB max
});
const mimeType = response.headers['content-type'] || 'application/octet-stream';
const buffer = response.data;
return res.json({
blob: buffer.toString('base64'),
mimeType,
});
} catch (e: unknown) {
let message = 'Ошибка загрузки файла по URI';
if (e instanceof Error) {
if (e.message.includes('timeout')) {
message = 'Превышено время ожидания при загрузке файла. Попробуйте позже.';
} else {
message = e.message;
}
}
return res.status(400).json({ error: message });
}
});
// Удаление объектов
router.delete('/buckets/:id/objects', async (req, res) => {
try {

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,122 @@
import { prisma } from '../../prisma/client';
import { Request, Response } from 'express';
import type { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
interface SerializedUserSummary {
id: number;
username: string;
operator: boolean;
email?: string | null;
}
interface SerializedAttachment {
id: number;
filename: string;
fileUrl: string;
fileSize: number;
mimeType: string;
createdAt: Date;
}
interface SerializedResponse {
id: number;
message: string;
isInternal: boolean;
createdAt: Date;
author: SerializedUserSummary | null;
attachments: SerializedAttachment[];
}
interface SerializedTicket {
id: number;
title: string;
message: string;
status: string;
priority: string;
category: string;
user: SerializedUserSummary | null;
assignedTo: number | null;
assignedOperator: SerializedUserSummary | null;
createdAt: Date;
updatedAt: Date;
closedAt: Date | null;
responseCount: number;
lastResponseAt: Date | null;
attachments: SerializedAttachment[];
responses: SerializedResponse[];
}
const serializeUser = (user: any | null): SerializedUserSummary | null => {
if (!user) {
return null;
}
return {
id: user.id,
username: user.username,
operator: Boolean(user.operator),
email: user.email ?? null,
};
};
const serializeAttachments = (attachments: any[] | undefined): SerializedAttachment[] => {
if (!attachments?.length) {
return [];
}
return attachments.map((attachment) => ({
id: attachment.id,
filename: attachment.filename,
fileUrl: attachment.fileUrl,
fileSize: attachment.fileSize,
mimeType: attachment.mimeType,
createdAt: attachment.createdAt,
}));
};
const serializeResponses = (responses: any[] | undefined): SerializedResponse[] => {
if (!responses?.length) {
return [];
}
return responses.map((response) => ({
id: response.id,
message: response.message,
isInternal: response.isInternal,
createdAt: response.createdAt,
author: serializeUser(response.operator ?? null),
attachments: serializeAttachments(response.attachments),
}));
};
const serializeTicket = (
ticket: any,
assignedOperatorsMap: Map<number, SerializedUserSummary>,
): SerializedTicket => {
const responses = serializeResponses(ticket.responses);
return {
id: ticket.id,
title: ticket.title,
message: ticket.message,
status: ticket.status,
priority: ticket.priority,
category: ticket.category,
user: serializeUser(ticket.user ?? null),
assignedTo: ticket.assignedTo ?? null,
assignedOperator: ticket.assignedTo ? assignedOperatorsMap.get(ticket.assignedTo) ?? null : null,
createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt,
closedAt: ticket.closedAt ?? null,
responseCount: responses.length,
lastResponseAt: responses.length ? responses[responses.length - 1]?.createdAt ?? null : null,
attachments: serializeAttachments(ticket.attachments),
responses,
};
};
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: (req, file, cb) => {
@@ -37,274 +150,530 @@ export const uploadTicketFiles = multer({
// Создать тикет
export async function createTicket(req: Request, res: Response) {
const { title, message, category = 'general', priority = 'normal' } = req.body;
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
const userId = Number((req as any).user?.id);
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
if (!title || !message) {
return res.status(400).json({ error: 'Необходимо указать title и message' });
}
try {
const ticket = await prisma.ticket.create({
data: {
title,
message,
data: {
title,
message,
userId,
category,
priority,
status: 'open'
status: 'open',
},
include: {
user: {
select: { id: true, username: true, email: true }
}
}
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
responses: {
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
// TODO: Отправить уведомление операторам о новом тикете
res.json(ticket);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка создания тикета:', err);
res.status(500).json({ error: 'Ошибка создания тикета' });
return res.status(500).json({ error: 'Ошибка создания тикета' });
}
}
// Получить тикеты (клиент — свои, оператор — все с фильтрами)
export async function getTickets(req: Request, res: Response) {
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const { status, category, priority, assignedTo } = req.query;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
const {
status,
category,
priority,
assigned,
search,
page: pageParam,
pageSize: pageSizeParam,
} = req.query;
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
const page = Number(pageParam) > 0 ? Number(pageParam) : 1;
const pageSize = Number(pageSizeParam) > 0 ? Math.min(Number(pageSizeParam), 50) : 10;
try {
const where: any = isOperator ? {} : { userId };
// Фильтры (только для операторов)
if (isOperator) {
if (status) where.status = status;
if (category) where.category = category;
if (priority) where.priority = priority;
if (assignedTo) where.assignedTo = Number(assignedTo);
if (typeof status === 'string' && status !== 'all') {
where.status = status;
}
const tickets = await prisma.ticket.findMany({
where,
include: {
responses: {
include: {
operator: {
select: { id: true, username: true, email: true }
}
if (typeof category === 'string' && category !== 'all') {
where.category = category;
}
if (typeof priority === 'string' && priority !== 'all') {
where.priority = priority;
}
if (typeof search === 'string' && search.trim().length > 1) {
where.OR = [
{ title: { contains: search.trim(), mode: 'insensitive' } },
{ message: { contains: search.trim(), mode: 'insensitive' } },
];
}
if (isOperator && typeof assigned === 'string') {
if (assigned === 'me') {
where.assignedTo = userId;
} else if (assigned === 'unassigned') {
where.assignedTo = null;
} else if (assigned === 'others') {
where.AND = [{ assignedTo: { not: null } }, { assignedTo: { not: userId } }];
}
}
const [tickets, total, statusBuckets, assignedToMe, unassigned] = await Promise.all([
prisma.ticket.findMany({
where,
include: {
responses: {
where: isOperator ? {} : { isInternal: false },
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
orderBy: { createdAt: 'asc' }
user: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
user: {
select: { id: true, username: true, email: true }
},
attachments: true
},
orderBy: { createdAt: 'desc' },
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.ticket.count({ where }),
prisma.ticket.groupBy({
by: ['status'],
_count: { _all: true },
where: isOperator ? {} : { userId },
}),
isOperator
? prisma.ticket.count({ where: { assignedTo: userId } })
: Promise.resolve(0),
isOperator
? prisma.ticket.count({ where: { assignedTo: null, status: { not: 'closed' } } })
: Promise.resolve(0),
]);
const assignedOperatorIds = tickets
.map((ticket) => ticket.assignedTo)
.filter((value): value is number => typeof value === 'number');
const assignedOperators = assignedOperatorIds.length
? await prisma.user.findMany({
where: { id: { in: assignedOperatorIds } },
select: { id: true, username: true, operator: true, email: true },
})
: [];
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
assignedOperators.forEach((operator) => {
assignedOperatorsMap.set(operator.id, serializeUser(operator)!);
});
const normalizedTickets = tickets.map((ticket) => serializeTicket(ticket, assignedOperatorsMap));
const statusMap = statusBuckets.reduce<Record<string, number>>((acc, bucket) => {
acc[bucket.status] = bucket._count._all;
return acc;
}, {});
const stats = {
open: statusMap.open ?? 0,
inProgress: statusMap.in_progress ?? 0,
awaitingReply: statusMap.awaiting_reply ?? 0,
resolved: statusMap.resolved ?? 0,
closed: statusMap.closed ?? 0,
assignedToMe: isOperator ? assignedToMe : undefined,
unassigned: isOperator ? unassigned : undefined,
};
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return res.json({
tickets: normalizedTickets,
meta: {
page,
pageSize,
total,
totalPages,
hasMore: page < totalPages,
},
stats,
});
res.json(tickets);
} catch (err) {
console.error('Ошибка получения тикетов:', err);
res.status(500).json({ error: 'Ошибка получения тикетов' });
return res.status(500).json({ error: 'Ошибка получения тикетов' });
}
}
// Получить один тикет по ID
export async function getTicketById(req: Request, res: Response) {
const ticketId = Number(req.params.id);
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
if (!ticketId) {
return res.status(400).json({ error: 'Некорректный идентификатор тикета' });
}
try {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
responses: {
where: isOperator ? {} : { isInternal: false }, // Клиенты не видят внутренние комментарии
where: isOperator ? {} : { isInternal: false },
include: {
operator: {
select: { id: true, username: true, email: true }
}
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' }
orderBy: { createdAt: 'asc' },
},
user: {
select: { id: true, username: true, email: true }
select: { id: true, username: true, operator: true, email: true },
},
attachments: true
}
attachments: true,
},
});
if (!ticket) {
return res.status(404).json({ error: 'Тикет не найден' });
}
// Проверка прав доступа
if (!isOperator && ticket.userId !== userId) {
return res.status(403).json({ error: 'Нет прав доступа к этому тикету' });
}
res.json(ticket);
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
if (ticket.assignedTo) {
const assignedOperator = await prisma.user.findUnique({
where: { id: ticket.assignedTo },
select: { id: true, username: true, operator: true, email: true },
});
if (assignedOperator) {
assignedOperatorsMap.set(assignedOperator.id, serializeUser(assignedOperator)!);
}
}
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка получения тикета:', err);
res.status(500).json({ error: 'Ошибка получения тикета' });
return res.status(500).json({ error: 'Ошибка получения тикета' });
}
}
// Ответить на тикет (клиент или оператор)
export async function respondTicket(req: Request, res: Response) {
const { ticketId, message, isInternal = false } = req.body;
const operatorId = (req as any).user?.id;
const actorId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!operatorId) return res.status(401).json({ error: 'Нет авторизации' });
if (!message) return res.status(400).json({ error: 'Сообщение не может быть пустым' });
// Только операторы могут оставлять внутренние комментарии
const actualIsInternal = isOperator ? isInternal : false;
if (!actorId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
const numericTicketId = Number(ticketId);
if (!numericTicketId) {
return res.status(400).json({ error: 'Некорректный ticketId' });
}
if (!message || !message.trim()) {
return res.status(400).json({ error: 'Сообщение не может быть пустым' });
}
try {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
// Клиент может отвечать только на свои тикеты
if (!isOperator && ticket.userId !== operatorId) {
const ticket = await prisma.ticket.findUnique({ where: { id: numericTicketId } });
if (!ticket) {
return res.status(404).json({ error: 'Тикет не найден' });
}
if (!isOperator && ticket.userId !== actorId) {
return res.status(403).json({ error: 'Нет прав' });
}
const response = await prisma.response.create({
data: {
ticketId,
operatorId,
message,
isInternal: actualIsInternal
data: {
ticketId: numericTicketId,
operatorId: actorId,
message: message.trim(),
isInternal: isOperator ? Boolean(isInternal) : false,
},
include: {
operator: {
select: { id: true, username: true, email: true }
}
}
});
// Обновляем статус тикета
let newStatus = ticket.status;
if (isOperator && ticket.status === 'open') {
newStatus = 'in_progress';
} else if (!isOperator && ticket.status === 'awaiting_reply') {
newStatus = 'in_progress';
}
await prisma.ticket.update({
where: { id: ticketId },
data: {
status: newStatus,
updatedAt: new Date()
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
});
// TODO: Отправить уведомление автору тикета (если ответил оператор)
res.json(response);
const updateData: any = {};
if (isOperator) {
if (!response.isInternal) {
updateData.status = 'awaiting_reply';
} else if (ticket.status === 'open') {
updateData.status = 'in_progress';
}
if (!ticket.assignedTo) {
updateData.assignedTo = actorId;
}
} else {
updateData.status = 'in_progress';
if (ticket.closedAt) {
updateData.closedAt = null;
}
}
const dataToApply = Object.keys(updateData).length ? updateData : { status: ticket.status };
await prisma.ticket.update({
where: { id: numericTicketId },
data: dataToApply,
});
const normalizedResponse: SerializedResponse = {
id: response.id,
message: response.message,
isInternal: response.isInternal,
createdAt: response.createdAt,
author: serializeUser(response.operator ?? null),
attachments: serializeAttachments(response.attachments),
};
return res.json({
response: normalizedResponse,
ticketStatus: updateData.status ?? ticket.status,
assignedTo: updateData.assignedTo ?? ticket.assignedTo ?? null,
});
} catch (err) {
console.error('Ошибка ответа на тикет:', err);
res.status(500).json({ error: 'Ошибка ответа на тикет' });
return res.status(500).json({ error: 'Ошибка ответа на тикет' });
}
}
// Изменить статус тикета (только оператор)
export async function updateTicketStatus(req: Request, res: Response) {
const { ticketId, status } = req.body;
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
}
const allowedStatuses = ['open', 'in_progress', 'awaiting_reply', 'resolved', 'closed'];
if (!allowedStatuses.includes(status)) {
if (typeof status !== 'string' || !allowedStatuses.includes(status)) {
return res.status(400).json({ error: 'Недопустимый статус' });
}
const numericTicketId = Number(ticketId);
if (!numericTicketId) {
return res.status(400).json({ error: 'Некорректный ticketId' });
}
try {
const ticket = await prisma.ticket.update({
where: { id: ticketId },
data: {
where: { id: numericTicketId },
data: {
status,
closedAt: status === 'closed' ? new Date() : null,
updatedAt: new Date()
},
include: {
user: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
responses: {
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
res.json(ticket);
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
if (ticket.assignedTo) {
const assignedUser = await prisma.user.findUnique({
where: { id: ticket.assignedTo },
select: { id: true, username: true, operator: true, email: true },
});
if (assignedUser) {
assignedOperatorsMap.set(assignedUser.id, serializeUser(assignedUser)!);
}
}
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка изменения статуса тикета:', err);
res.status(500).json({ error: 'Ошибка изменения статуса тикета' });
return res.status(500).json({ error: 'Ошибка изменения статуса тикета' });
}
}
// Назначить тикет на оператора (только оператор)
export async function assignTicket(req: Request, res: Response) {
const { ticketId, operatorId } = req.body;
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
}
const numericTicketId = Number(ticketId);
const numericOperatorId = Number(operatorId);
if (!numericTicketId || !numericOperatorId) {
return res.status(400).json({ error: 'Некорректные данные' });
}
try {
const operator = await prisma.user.findUnique({
where: { id: numericOperatorId },
select: { id: true, operator: true },
});
if (!operator || operator.operator !== 1) {
return res.status(400).json({ error: 'Пользователь не является оператором' });
}
const ticket = await prisma.ticket.update({
where: { id: ticketId },
data: {
assignedTo: operatorId,
where: { id: numericTicketId },
data: {
assignedTo: numericOperatorId,
status: 'in_progress',
updatedAt: new Date()
},
include: {
user: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
responses: {
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
res.json(ticket);
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
const assignedOperatorUser = await prisma.user.findUnique({
where: { id: numericOperatorId },
select: { id: true, username: true, operator: true, email: true },
});
if (assignedOperatorUser) {
assignedOperatorsMap.set(assignedOperatorUser.id, serializeUser(assignedOperatorUser)!);
}
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка назначения тикета:', err);
res.status(500).json({ error: 'Ошибка назначения тикета' });
return res.status(500).json({ error: 'Ошибка назначения тикета' });
}
}
// Закрыть тикет (клиент или оператор)
export async function closeTicket(req: Request, res: Response) {
const { ticketId } = req.body;
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
const numericTicketId = Number(ticketId);
if (!numericTicketId) {
return res.status(400).json({ error: 'Некорректный ticketId' });
}
try {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
const ticket = await prisma.ticket.findUnique({ where: { id: numericTicketId } });
if (!ticket) {
return res.status(404).json({ error: 'Тикет не найден' });
}
if (!isOperator && ticket.userId !== userId) {
return res.status(403).json({ error: 'Нет прав' });
}
await prisma.ticket.update({
where: { id: ticketId },
data: {
where: { id: numericTicketId },
data: {
status: 'closed',
closedAt: new Date(),
updatedAt: new Date()
...(isOperator ? {} : { assignedOperatorId: null }),
},
});
res.json({ success: true, message: 'Тикет закрыт' });
return res.json({ success: true, message: 'Тикет закрыт' });
} catch (err) {
console.error('Ошибка закрытия тикета:', err);
res.status(500).json({ error: 'Ошибка закрытия тикета' });
return res.status(500).json({ error: 'Ошибка закрытия тикета' });
}
}