/
leflop
/
queue_system_backend
Обзор
Документация
Войти
/
leflop
/
queue_system_backend
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
server.js
1 041 строка
27 KB
leflop
Добавлен функционал и сервер WebSocket
14 авг 2025, 21:46
14 авг 2025, 21:46
3e32f83
Код
Авторство
О чём код?
const express = require("express"); const bcrypt = require("bcrypt"); const socketIo = require("socket.io"); const http = require("http"); const cors = require("cors"); const jwt = require("jsonwebtoken"); const { User, Group, Queue, QueueParticipant, Subject, SwapRequest, sequelize, initializeDatabase } = require('./models'); const { Op, ValidationError, UniqueConstraintError } = require("sequelize"); require('dotenv').config(); const app = express(); const server = http.createServer(app); const io = socketIo(server, { cors: { origin: process.env.FRONTEND_URL || "http://localhost:3000", methods: ["GET", "POST"] } }); app.use(express.json()); app.use(cors()); const JWT_SECRET = process.env.JWT_SECRET; const DEFAULT_TIME = process.env.DEFAULT_TIME; const socketAuth = async (socket, next) => { try { const token = socket.handshake.auth.token; if (!token) { return next(new Error('Authentication failed!')); } const decoded = jwt.verify(token, JWT_SECRET); const user = await User.findByPk(decoded.user_id, { include: [{ model: 'Group', as: 'group', attributes: ['id', 'code'] }] }); if (!user) { return next(new Error('No such user found')); } socket.user = user; if (!userSockets.has(user.id)) { userSockets.set(user.id, new Set()); } userSockets.get(user.id).add(socket.id); next(); } catch (error) { next(new Error('Authentication error: Invalid token')); } } io.use(socketAuth); io.on('connection', (socket) => { console.log(`User ${socket.user.name} connected (ID: ${socket.id})`); socket.join(`group_${socket.user.group_id}`); socket.on('join_queue', (queueId) => { socket.join(`queue_${queueId}`); console.log(`User ${socket.user.name} joined queue room ${queueId}`); }); socket.on('leave_queue', (queueId) => { socket.leave(`queue_${queueId}`); console.log(`User ${socket.user.name} left queue room ${queueId}`); }); socket.on('disconnect', () => { const sockets = userSockets.get(socket.user.id); if (sockets) { sockets.delete(socket.id); if (sockets.size === 0) { userSockets.delete(socket.user.id); } } console.log(`User ${socket.user.name} disconnected`); }); }); const emitQueueUpdate = async (queueId, eventType = 'queue_updated', additionalData = {}) => { try { const queue = await loadQueueData(queueId); if (!queue) return; io.to(`queue_${queueId}`).emit(eventType, { queue, timestamp: new Date(), ...additionalData }); } catch (error) { console.error('Error getting queue_update: ', error); } } const emitToGroup = (groupId, eventType, data) => { try { io.to(`group-${groupId}`).emit(eventType, { ...data, timestamp: new Date().toISOString() }); console.log(`Emitted '${eventType}' to group-${groupId}`); } catch (error) { console.error(`Failed to emit '${eventType}' to group-${groupId}:`, error); } }; const emitToUser = async (userId, event, data) => { const sockets = userSockets.get(userId); if (sockets) { sockets.forEach(socketId => { io.to(socketId).emit(event, data); }); } }; const userSockets = new Map(); const loadQueueData = async (queueId) => { const queue = await Queue.findByPk(queueId, { attributes: ['id', 'name', 'lesson_dateTime', 'subject_id', 'group_id', 'is_active', 'createdAt'], include: [ { model: User, as: 'participants', attributes: ['id', 'name', 'surname', 'notification_preferences'], through: { model: QueueParticipant, as: 'QueueParticipant', attributes: [ 'id', 'position', 'status', 'actual_duration', 'joined_at', 'started_at', 'ended_at', 'is_first_time' ] } }, { model: Subject, as: 'subject', attributes: ['id', 'name'] } ] }); if (!queue) return null; queue.participants.sort((a, b) => a.QueueParticipant.position - b.QueueParticipant.position); queue.participants = calculateWaitTime(queue.participants); return queue; }; const authenticateToken = async (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { return res.status(401).json({error: "Необходим токен доступа"}); } try { const decoded = jwt.verify(token, JWT_SECRET); const user = await User.findByPk(decoded.user_id, { include: [{ model: Group, as: 'group', attributes: ['id', 'code'] }] }); if (!user) { return res.status(401).json({error: "Пользователь не найден"}); } req.user = user; next(); } catch (error) { return res.status(403).json({error: "Недействительный токен"}) } } const errorHandler = (error, req, res, next) => { console.error('API error: ', error); if (error instanceof ValidationError) { return res.status(400).json({ error: 'Validation error', details: error.errors.map(e => ({ field: e.path, message: e.message })) }); } if (error instanceof UniqueConstraintError) { return res.status(409).json({ error: 'Duplicate entry', message: 'This record already exists' }); } res.status(500).json({ error: 'Internal server error', message: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong' }); } const checkValidity = async (req, res, next) => { const VALID_SORT_FIELDS = ['lesson_dateTime', 'lesson_time', 'subject']; const VALID_SORT_ORDERS = ['ASC', 'DESC']; const { sortBy = "lesson_dateTime", sortOrder = "ASC", lesson_dateTime, subject } = req.query; if (!VALID_SORT_FIELDS.includes(sortBy)) { return res.status(400).json({ error: "Невалидное поле сортировки!" }); } if (!VALID_SORT_ORDERS.includes(sortOrder)) { return res.status(400).json({ error: "Невалидный порядок сортировки!" }); } if (lesson_dateTime) { const date = new Date(lesson_dateTime); if (isNaN(date.getTime())) { return res.status(400).json({ error: "Невалидный формат даты!" }); } } if (subject) { try { const foundSubject = await Subject.findOne({ where: { name: subject, group_id: req.user.group_id } }); if (!foundSubject) { return res.status(400).json({ error: `Предмета ${subject} не существует!` }); } req.subject_id = foundSubject.id; } catch (error) { return next(error); } } next(); } const loadQueue = async (req, res, next) => { const queueId = req.params.queueId; const user = req.user; const queue = await loadQueueData(queueId); if (!queue) { return res.status(404).json({ error: "Очередь не найдена" }); } if (queue.group_id !== user.group_id) { return res.status(403).json({ error: "Вы принадлежите не той группе!" }); } req.queue = queue; next(); }; const calculateWaitTime = (participants) => { const completed = participants.filter(p => p.QueueParticipant.status === 'completed' ); const avgPresentationTime = completed.length > 0 ? Math.round( completed.reduce((sum, p) => { return sum + (p.QueueParticipant.actual_duration || DEFAULT_TIME); }, 0) / completed.length ) : DEFAULT_TIME; let cumulativeWait = 0; return participants.map(p => { const plainParticipant = p.toJSON ? p.toJSON() : p; const status = plainParticipant.QueueParticipant.status; if (status === 'completed' || status === 'presenting') { plainParticipant.estimatedWaitTime = 0; cumulativeWait += status === 'completed' ? (plainParticipant.QueueParticipant.actual_duration || avgPresentationTime) : avgPresentationTime; } else if (status === 'waiting') { plainParticipant.estimatedWaitTime = cumulativeWait; cumulativeWait += avgPresentationTime; } else { plainParticipant.estimatedWaitTime = 0; } return plainParticipant; }); }; const requireRoles = (roles) => (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ error: "Недостаточно прав!" }); } next(); } app.post('/api/auth/register', async (req, res, next) => { if (!req.body) return res.sendStatus(400); const transaction = await sequelize.transaction(); try { const {name ,surname, email, password, groupCode} = req.body; if (!name || !surname || !email || !password || !groupCode) { await transaction.rollback(); return res.status(400).json({error: 'Заполните все поля!'}); } const [group] = await Group.findOrCreate({ where: {code: groupCode}, defaults: {code: groupCode}, transaction }) const password_hash = await bcrypt.hash(password, 12); const user = await User.create({ email: email.trim(), password_hash, name: name.trim(), surname: surname.trim(), group_id: group.id }, { transaction }); await transaction.commit(); const token = jwt.sign( { user_id: user.id, email: user.email}, JWT_SECRET, { expiresIn: '1h' }); res.status(201).json({ message: "Пользователь создан успешно!", token, user: { id: user.id, name: user.name, surname: user.surname, email: user.email, group: { id: group.id, code: group.code } } }); } catch(error) { await transaction.rollback(); next(error); } }); app.post('/api/auth/login', async (req, res, next) => { if (!req.body) { return res.sendStatus(400); } try { const {email, password} = req.body; if (!email || !password) { return res.status(400).json({ error: "Не все поля заполнены!" }); } const user = await User.findOne({ where: { email: email.trim() }, include: [{ model: Group, as: 'group', attributes: ['id', 'code'] }] }); if (!user) { return res.status(401).json({ error: "Пользователь не найден!" }); } const validatePassword = await bcrypt.compare(password, user.password_hash); if (!validatePassword) { return res.status(401).json({ error: "Неверный пароль!" }); } const token = jwt.sign( { user_id: user.id, email: user.email}, JWT_SECRET, { expiresIn: '1h' }); res.status(201).json({ message: "Вход успешный!", token, user: { id: user.id, name: user.name, surname: user.surname, email: user.email, group: { id: user.group_id, code: user.group.code } } }); } catch (error) { next(error); } }); app.put('/api/profile', authenticateToken, async (req, res, next) => { if (!req.body) { return res.sendStatus(400); } const user = req.user; const {name = user.name, surname = user.surname, groupCode = user.group.code, swap_request = user.notification_preferences.swap_request, next_in_line = user.notification_preferences.next_in_line, someone_skipped = user.notification_preferences.someone_skipped, before_lesson = user.notification_preferences.before_lesson} = req.body; const transaction = await sequelize.transaction(); try { let group = await Group.findOne({ where: { code: groupCode.trim() }, transaction }); if (!group) { group = Group.create({ code: groupCode.trim() }, { transaction }); } user.name = name.trim(); user.surname = surname.trim(); user.group_id = group.id; user.notification_preferences = { swap_request, next_in_line, someone_skipped, before_lesson }; await user.save({ transaction }); await transaction.commit(); return res.status(201).send(user.toJSON()); } catch (error) { await transaction.rollback(); next(error); } }); app.put('/api/:userId/role', authenticateToken, requireRoles(['admin', 'headman']), async (req, res, next) => { const transaction = await sequelize.transaction(); const { role } = req.body; try { const userToBeEdited = await User.findOne({ where: { id: req.params.userId }}); if (!userToBeEdited) { await transaction.rollback(); return res.status(400).json({ error: "Пользователь не найден!" }); } userToBeEdited = await User.update( { role: role }, { where: { id: userToBeEdited.id } }, transaction ); await transaction.commit(); return res.status(200).json({ message: "Роль успешно присвоена.", user: { ...userToBeEdited } }); } catch (error) { await transaction.rollback(); next(error); } }); app.post('/api/queues', authenticateToken, async (req, res, next) => { if (!req.body) { return res.sendStatus(400); } const { name, lesson_dateTime, subject_name } = req.body; if (!name?.trim() || !lesson_dateTime || !subject_name) { return res.status(400).json({ error: "Все поля должны быть заполнены!" }); } const parsedDate = new Date(lesson_dateTime); if (isNaN(parsedDate)) { return res.status(400).json({ error: "Неверный формат даты!" }); } const user = req.user; const transaction = await sequelize.transaction(); try { const [subject] = await Subject.findOrCreate({ where: { name: subject_name, group_id: user.group_id }, defaults: { name: subject_name, group_id: user.group_id }, transaction }); const prevQueue = await Queue.findOne({ where: { subject_id: subject.id, group_id: user.group_id, is_active: true }, include: [{ model: User, as: 'participants', attributes: ['id', 'name', 'surname'], through: { model: QueueParticipant, attributes: ['position', 'is_first_time', 'status'], where: { status: 'waiting' } } }], transaction }); const newQueue = await Queue.create({ name: name.trim(), lesson_dateTime: lesson_dateTime, subject_id: subject.id, group_id: user.group_id }, { transaction }); if (prevQueue) { const participantsToImport = prevQueue.participants.map((participant, index) => ({ user_id: participant.id, queue_id: newQueue.id, position: index + 1, is_first_time: participant.QueueParticipant.is_first_time, status: 'waiting', joined_at: participant.QueueParticipant.joined_at, started_at: participant.QueueParticipant.started_at, ended_at: null, actual_duration: 0 })); await QueueParticipant.bulkCreate(participantsToImport, { transaction }); await prevQueue.update({ is_active: false }, { transaction }); } const createdQueue = await Queue.findByPk(newQueue.id, { include: [ { model: Subject, as: 'subject', attributes: ['id', 'name'] }, { model: User, as: 'participants', through: { model: QueueParticipant, attributes: ['position', 'is_first_time', 'status', 'joined_at'] }, attributes: ['id', 'name', 'surname'] } ], transaction }); createdQueue.participants.sort((a, b) => a.QueueParticipant.position - b.QueueParticipant.position); await transaction.commit(); res.status(201).json({ message: "Очередь создана успешно!", queue: createdQueue }); } catch (error) { await transaction.rollback(); next(error); } }); app.get('/api/queues', authenticateToken, checkValidity, async (req, res, next) => { try { const { sortBy = "lesson_dateTime", sortOrder = "ASC", lesson_dateTime, subject_name } = req.query; const where = { group_id: req.user.group_id }; if (lesson_dateTime) { const selectedDate = new Date(lesson_dateTime); const startOfDay = new Date(selectedDate); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(selectedDate); endOfDay.setHours(23, 59, 59, 999); where.lesson_dateTime = { [Op.between]: [startOfDay, endOfDay] }; } if (subject_name) { where.subject_id = req.subject_id; } const queues = await Queue.findAll({ where, include: [ { model: User, as: 'participants', attributes: ['id', 'name', 'surname'], through: { model: QueueParticipant, attributes: ['position', 'is_first_time', 'status', 'joined_at'] } } ], order: [[sortBy, sortOrder]] }); const queueWithStats = queues.map(queue => ({ ...queue.toJSON(), participant_count: queue.participants.length })); res.json(queueWithStats); } catch (error) { next(error); } }); app.get('/api/queues/:queueId', authenticateToken, loadQueue, async (req, res, next) => { try { const { queue } = req; res.status(200).json({ message: "Данные очереди получены!", queue, queue_participants: queue.participants }) } catch (error) { next(error); } }); app.delete('/api/queues/:queueId', authenticateToken, requireRoles(['admin', 'headman', 'assistant']), async (req, res, next) => { const transaction = await sequelize.transaction(); if (!req.params.queueId) { return res.status(400).json({ error: "Выберите очередь!" }); } try { await Queue.destroy({ where: { id: req.params.queueId }, force: true }, { transaction }); res.status(400).send({ message: "Очередь успешно удалена!" }); } catch (error) { await transaction.rollback(); next(error); } }); app.post('/api/queues/:queueId/join', authenticateToken, loadQueue, async (req, res, next) => { const user = req.user; const transaction = await sequelize.transaction(); const { queue } = req; const currentParticipants = queue.participants; if (!queue.is_active) { return res.status(400).json({ error: "Нельзя присоединиться к завершённой очереди! Попробуйте выбрать или создать следующее занятие." }); } try { const previousEntries = await QueueParticipant.findAll({ where: { queue_id: queue.id, user_id: user.id }, transaction }); const isActive = previousEntries?.some(p => p.status === 'presenting' || p.status === 'waiting'); if (isActive) { return res.status(400).json({ error: "Вы все еще активны в данной очереди! Повторная запись доступна только после завершения текущей." }); } const is_first_time = previousEntries.length === 0; const lastFirstTimerIndex = currentParticipants .findLastIndex(p => p.QueueParticipant.is_first_time); let insertPosition; if (is_first_time) { insertPosition = lastFirstTimerIndex + 2; } else { insertPosition = currentParticipants.length + 1; } const newParticipant = await QueueParticipant.create({ queue_id: queue.id, user_id: user.id, position: insertPosition, is_first_time: is_first_time }, { transaction }); const participantsToReposition = currentParticipants.filter( p => p.position >= newParticipant.position && p.id !== newParticipant.id ); for (const p of participantsToReposition) { p.position += 1; await p.save({ transaction }); } await newParticipant.save({ transaction }) await transaction.commit(); await emitQueueUpdate(queue.id, 'user_joined', { participant: { id: user.id, name: user.name, surname: user.surname, position: newParticipant.position, is_first_time: newParticipant.is_first_time } }); return res.status(201).json({ message: "Успешно присоединились к очереди", participant: { id: newParticipant.id, user_id: user.id, position: newParticipant.position, is_first_time: newParticipant.is_first_time, status: newParticipant.status, joined_at: newParticipant.joined_at } }) } catch (error) { await transaction.rollback(); next(error); } }); app.post('/api/queues/:queueId/next', authenticateToken, requireRoles(['admin', 'headman', 'assistant']), loadQueue, async (req, res, next) => { const queue = req.queue; const currentParticipants = queue.participants; const transaction = await sequelize.transaction(); try { const participant = currentParticipants.find(p => p.QueueParticipant.status === 'presenting'); if (!participant) { await transaction.rollback(); return res.status(400).json({ error: "Выбранного пользователя не существует!" }); } const currentQueueParticipant = participant.QueueParticipant; const endTime = new Date(); const startTime = new Date(currentQueueParticipant.started_at); const actualDuration = Math.round((endTime - startTime) / 1000); await QueueParticipant.update( { ended_at: endTime, status: 'completed', actual_duration: actualDuration }, { where: { id: currentQueueParticipant.id }, transaction } ); const waiting = currentParticipants .filter(p => p.QueueParticipant.status === 'waiting') .sort((a, b) => a.QueueParticipant.position - b.QueueParticipant.position); const nextParticipant = waiting[0]; if (!nextParticipant) { await Queue.update( { is_active: false }, { where: { id: queue.id }, transaction } ); await transaction.commit(); await emitQueueUpdate(queue.id, 'queue_completed'); return res.status(200).json({ message: "Очередь завершена!" }); } await QueueParticipant.update( { started_at: new Date(), status: 'presenting' }, { where: { id: nextParticipant.QueueParticipant.id }, transaction } ); await transaction.commit(); await emitQueueUpdate(queue.id, 'next_participant', { previousParticipant: { id: participant.id, name: participant.name, surname: participant.surname, duration: actualDuration }, currentParticipant: { id: nextParticipant.id, name: nextParticipant.name, surname: nextParticipant.surname } }); const nextInLine = waiting[1]; if (nextInLine && nextInLine.notification_preferences['next_in_line']) { try { await emitToUser(nextInLine.id, 'next_in_line'); } catch (error) { console.error('Failed to notify! ', error); } } res.status(200).json({ message: "Очередь успешно продвинулась!", currentParticipant: { id: nextParticipant.id, name: nextParticipant.name, surname: nextParticipant.surname, QueueParticipant: { ...nextParticipant.QueueParticipant, started_at: new Date(), status: 'presenting' } } }); } catch (error) { await transaction.rollback(); next(error); } }); app.post('/api/queues/:queueId/skip', authenticateToken, loadQueue, async (req, res, next) => { const user = req.user; const queue = req.queue; const transaction = await sequelize.transaction(); try { const participant = queue.participants.find(p => p.id === user.id); if (!participant) { await transaction.rollback(); return res.status(400).json({ error: "Вас нет в очереди!" }); } const currentQueueParticipant = participant.QueueParticipant; if (currentQueueParticipant.status !== 'waiting') { await transaction.rollback(); return res.status(400).json({ error: "Вы не можете пропустить место в очереди!" }); } await QueueParticipant.update( { status: 'skipped' }, { where: { queue_id: queue.id, user_id: user.id }, transaction } ); await transaction.commit(); await emitQueueUpdate(queue.id, 'participant_skipped', { participant: { id: user.id, name: user.name, surname: user.surname, position: currentQueueParticipant.position } }); try { emitToGroup(participant.group_id, 'someone_skipped', { message: `${user.name} ${user.surname} пропустил/а очередь.`, userId: user.id, userName: `${user.name} ${user.surname}` }); } catch (error) { console.error('Failed to notify!', error); } return res.status(200).json({ message: "Очередь успешно пропущена!", currentParticipant: participant }) } catch (error) { await transaction.rollback(); next(error); } }); app.post('/api/queues/:queueId/start', authenticateToken, requireRoles(['admin', 'headman', 'assistant']), loadQueue, async (req, res, next) => { const queue = req.queue; const participants = queue.participants; const transaction = await sequelize.transaction(); try { const currentlyPresenting = participants.find(p => p.QueueParticipant.status === 'presenting'); if (currentlyPresenting) { await transaction.rollback(); return res.status(400).json({ error: "Очередь уже запущена!" }); } const firstParticipant = participants .filter(p => p.QueueParticipant.status === 'waiting') .sort((a, b) => a.QueueParticipant.position - b.QueueParticipant.position)[0]; if (!firstParticipant) { await transaction.rollback(); return res.status(400).json({ error: "Нет участников в очереди!" }); } await QueueParticipant.update( { started_at: new Date(), status: 'presenting' }, { where: { id: firstParticipant.QueueParticipant.id }, transaction } ); if (!queue.is_active) { await Queue.update( { is_active: true }, { where: { id: queue.id }, transaction } ); } await transaction.commit(); await emitQueueUpdate(queue.id, 'queue_started', { participant: { id: firstParticipant.id, name: firstParticipant.name, surname: firstParticipant.surname } }); return res.status(200).json({ message: "Очередь успешно запущена!", currentParticipant: { id: firstParticipant.id, name: firstParticipant.name, surname: firstParticipant.surname, QueueParticipant: { ...firstParticipant.QueueParticipant, started_at: new Date(), status: 'presenting' } } }); } catch (error) { next(error); await transaction.rollback(); } }); app.use(errorHandler); (async () => { await initializeDatabase(); server.listen(5000, () => console.log(`Server on ${process.env.SERVER}`)); })();