update README
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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: 'Не удалось загрузить профиль. Попробуйте позже.' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 : 'Неизвестная ошибка'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
273
ospabhost/backend/src/modules/storage/fileScanner.ts
Normal file
273
ospabhost/backend/src/modules/storage/fileScanner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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: 'Ошибка закрытия тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user