674 lines
18 KiB
JavaScript
674 lines
18 KiB
JavaScript
const router = require('express').Router();
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
const timer = (time = 300) => (req, res, next) => setTimeout(next, time);
|
||
|
||
// Helper functions
|
||
const loadJSON = (filename) => {
|
||
const filePath = path.join(__dirname, 'data', filename);
|
||
const data = fs.readFileSync(filePath, 'utf8');
|
||
return JSON.parse(data);
|
||
};
|
||
|
||
const respond = (res, body) => {
|
||
res.json({ success: true, body });
|
||
};
|
||
|
||
const respondError = (res, message, statusCode = 400) => {
|
||
res.status(statusCode).json({
|
||
success: false,
|
||
body: null,
|
||
error: { message }
|
||
});
|
||
};
|
||
|
||
// In-memory storage (resets on server restart)
|
||
let tasksCache = null;
|
||
let chainsCache = null;
|
||
let usersCache = null;
|
||
let submissionsCache = null;
|
||
let statsCache = null;
|
||
let statsV2Cache = null;
|
||
|
||
const getTasks = () => {
|
||
if (!tasksCache) tasksCache = loadJSON('tasks.json');
|
||
return tasksCache;
|
||
};
|
||
|
||
const getChains = () => {
|
||
if (!chainsCache) chainsCache = loadJSON('chains.json');
|
||
return chainsCache;
|
||
};
|
||
|
||
const getUsers = () => {
|
||
if (!usersCache) usersCache = loadJSON('users.json');
|
||
return usersCache;
|
||
};
|
||
|
||
const getSubmissions = () => {
|
||
if (!submissionsCache) submissionsCache = loadJSON('submissions.json');
|
||
return submissionsCache;
|
||
};
|
||
|
||
const getStats = () => {
|
||
if (!statsCache) statsCache = loadJSON('stats.json');
|
||
return statsCache;
|
||
};
|
||
|
||
const getStatsV2 = () => {
|
||
if (!statsV2Cache) statsV2Cache = loadJSON('stats-v2.json');
|
||
return statsV2Cache;
|
||
};
|
||
|
||
// Enrich SystemStatsV2 with real user ids/nicknames from users.json
|
||
const getStatsV2WithUsers = () => {
|
||
const statsV2 = getStatsV2();
|
||
const users = getUsers();
|
||
|
||
const mapParticipant = (participant, index) => {
|
||
const user = users[index];
|
||
if (!user) return participant;
|
||
|
||
return {
|
||
...participant,
|
||
userId: user.id,
|
||
nickname: user.nickname,
|
||
};
|
||
};
|
||
|
||
return {
|
||
...statsV2,
|
||
activeParticipants: statsV2.activeParticipants.map(mapParticipant),
|
||
chainsDetailed: statsV2.chainsDetailed.map((chain) => ({
|
||
...chain,
|
||
participantProgress: chain.participantProgress.map(mapParticipant),
|
||
})),
|
||
};
|
||
};
|
||
|
||
router.use(timer());
|
||
|
||
// ============= TASKS =============
|
||
|
||
// GET /api/challenge/tasks
|
||
router.get('/challenge/tasks', (req, res) => {
|
||
const tasks = getTasks();
|
||
respond(res, tasks);
|
||
});
|
||
|
||
// GET /api/challenge/task/:id
|
||
router.get('/challenge/task/:id', (req, res) => {
|
||
const tasks = getTasks();
|
||
const task = tasks.find(t => t.id === req.params.id);
|
||
|
||
if (!task) {
|
||
return respondError(res, 'Task not found', 404);
|
||
}
|
||
|
||
respond(res, task);
|
||
});
|
||
|
||
// POST /api/challenge/task
|
||
router.post('/challenge/task', (req, res) => {
|
||
const { title, description, hiddenInstructions } = req.body;
|
||
|
||
if (!title || !description) {
|
||
return respondError(res, 'Title and description are required');
|
||
}
|
||
|
||
const tasks = getTasks();
|
||
const newTask = {
|
||
_id: `task_${Date.now()}`,
|
||
id: `task_${Date.now()}`,
|
||
title,
|
||
description,
|
||
hiddenInstructions: hiddenInstructions || undefined,
|
||
creator: {
|
||
sub: 'teacher-123',
|
||
preferred_username: 'current_teacher',
|
||
email: 'teacher@example.com'
|
||
},
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
|
||
tasks.push(newTask);
|
||
|
||
// Update stats
|
||
const stats = getStats();
|
||
stats.tasks = tasks.length;
|
||
|
||
respond(res, newTask);
|
||
});
|
||
|
||
// PUT /api/challenge/task/:id
|
||
router.put('/challenge/task/:id', (req, res) => {
|
||
const tasks = getTasks();
|
||
const taskIndex = tasks.findIndex(t => t.id === req.params.id);
|
||
|
||
if (taskIndex === -1) {
|
||
return respondError(res, 'Task not found', 404);
|
||
}
|
||
|
||
const { title, description, hiddenInstructions } = req.body;
|
||
const task = tasks[taskIndex];
|
||
|
||
if (title) task.title = title;
|
||
if (description) task.description = description;
|
||
if (hiddenInstructions !== undefined) {
|
||
task.hiddenInstructions = hiddenInstructions || undefined;
|
||
}
|
||
task.updatedAt = new Date().toISOString();
|
||
|
||
respond(res, task);
|
||
});
|
||
|
||
// DELETE /api/challenge/task/:id
|
||
router.delete('/challenge/task/:id', (req, res) => {
|
||
const tasks = getTasks();
|
||
const taskIndex = tasks.findIndex(t => t.id === req.params.id);
|
||
|
||
if (taskIndex === -1) {
|
||
return respondError(res, 'Task not found', 404);
|
||
}
|
||
|
||
tasks.splice(taskIndex, 1);
|
||
|
||
// Update stats
|
||
const stats = getStats();
|
||
stats.tasks = tasks.length;
|
||
|
||
respond(res, { success: true });
|
||
});
|
||
|
||
// ============= CHAINS =============
|
||
|
||
// GET /api/challenge/chains (user-facing list: only active chains)
|
||
router.get('/challenge/chains', (req, res) => {
|
||
const chains = getChains();
|
||
const activeChains = chains.filter(c => c.isActive !== false);
|
||
respond(res, activeChains);
|
||
});
|
||
|
||
// GET /api/challenge/chains/admin (admin list: all chains)
|
||
router.get('/challenge/chains/admin', (req, res) => {
|
||
const chains = getChains();
|
||
respond(res, chains);
|
||
});
|
||
|
||
// GET /api/challenge/chain/:id
|
||
router.get('/challenge/chain/:id', (req, res) => {
|
||
const chains = getChains();
|
||
const chain = chains.find(c => c.id === req.params.id);
|
||
|
||
if (!chain) {
|
||
return respondError(res, 'Chain not found', 404);
|
||
}
|
||
|
||
respond(res, chain);
|
||
});
|
||
|
||
// POST /api/challenge/chain
|
||
router.post('/challenge/chain', (req, res) => {
|
||
const { name, taskIds, isActive } = req.body;
|
||
|
||
if (!name || !taskIds || !Array.isArray(taskIds)) {
|
||
return respondError(res, 'Name and taskIds array are required');
|
||
}
|
||
|
||
const chains = getChains();
|
||
const allTasks = getTasks();
|
||
|
||
// Populate tasks
|
||
const populatedTasks = taskIds.map(taskId => {
|
||
const task = allTasks.find(t => t.id === taskId);
|
||
return task ? {
|
||
_id: task._id,
|
||
id: task.id,
|
||
title: task.title,
|
||
description: task.description,
|
||
createdAt: task.createdAt,
|
||
updatedAt: task.updatedAt
|
||
} : null;
|
||
}).filter(t => t !== null);
|
||
|
||
const newChain = {
|
||
_id: `chain_${Date.now()}`,
|
||
id: `chain_${Date.now()}`,
|
||
name,
|
||
tasks: populatedTasks,
|
||
isActive: isActive !== undefined ? !!isActive : true,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
|
||
chains.push(newChain);
|
||
|
||
// Update stats
|
||
const stats = getStats();
|
||
stats.chains = chains.length;
|
||
|
||
respond(res, newChain);
|
||
});
|
||
|
||
// PUT /api/challenge/chain/:id
|
||
router.put('/challenge/chain/:id', (req, res) => {
|
||
const chains = getChains();
|
||
const chainIndex = chains.findIndex(c => c.id === req.params.id);
|
||
|
||
if (chainIndex === -1) {
|
||
return respondError(res, 'Chain not found', 404);
|
||
}
|
||
|
||
const { name, taskIds, tasks, isActive } = req.body;
|
||
const chain = chains[chainIndex];
|
||
|
||
if (name) chain.name = name;
|
||
|
||
const effectiveTaskIds = Array.isArray(taskIds) ? taskIds : (Array.isArray(tasks) ? tasks : null);
|
||
|
||
if (effectiveTaskIds) {
|
||
const allTasks = getTasks();
|
||
const populatedTasks = effectiveTaskIds.map(taskId => {
|
||
const task = allTasks.find(t => t.id === taskId);
|
||
return task ? {
|
||
_id: task._id,
|
||
id: task.id,
|
||
title: task.title,
|
||
description: task.description,
|
||
createdAt: task.createdAt,
|
||
updatedAt: task.updatedAt
|
||
} : null;
|
||
}).filter(t => t !== null);
|
||
|
||
chain.tasks = populatedTasks;
|
||
}
|
||
|
||
if (isActive !== undefined) {
|
||
chain.isActive = !!isActive;
|
||
}
|
||
|
||
chain.updatedAt = new Date().toISOString();
|
||
|
||
respond(res, chain);
|
||
});
|
||
|
||
// DELETE /api/challenge/chain/:id
|
||
router.delete('/challenge/chain/:id', (req, res) => {
|
||
const chains = getChains();
|
||
const chainIndex = chains.findIndex(c => c.id === req.params.id);
|
||
|
||
if (chainIndex === -1) {
|
||
return respondError(res, 'Chain not found', 404);
|
||
}
|
||
|
||
chains.splice(chainIndex, 1);
|
||
|
||
// Update stats
|
||
const stats = getStats();
|
||
stats.chains = chains.length;
|
||
|
||
respond(res, { success: true });
|
||
});
|
||
|
||
// POST /api/challenge/chain/:chainId/duplicate
|
||
router.post('/challenge/chain/:chainId/duplicate', (req, res) => {
|
||
const chains = getChains();
|
||
const chainIndex = chains.findIndex(c => c.id === req.params.chainId);
|
||
|
||
if (chainIndex === -1) {
|
||
return respondError(res, 'Chain not found', 404);
|
||
}
|
||
|
||
const originalChain = chains[chainIndex];
|
||
const { name } = req.body;
|
||
|
||
// Generate new name if not provided
|
||
const newName = name || `Копия - ${originalChain.name}`;
|
||
|
||
// Create duplicate with same tasks but inactive
|
||
const duplicatedChain = {
|
||
_id: `chain_${Date.now()}`,
|
||
id: `chain_${Date.now()}`,
|
||
name: newName,
|
||
tasks: originalChain.tasks.map(task => ({
|
||
_id: task._id,
|
||
id: task.id,
|
||
title: task.title,
|
||
description: task.description,
|
||
createdAt: task.createdAt,
|
||
updatedAt: task.updatedAt
|
||
})),
|
||
isActive: false,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
|
||
chains.push(duplicatedChain);
|
||
|
||
// Update stats
|
||
const stats = getStats();
|
||
stats.chains = chains.length;
|
||
|
||
respond(res, duplicatedChain);
|
||
});
|
||
|
||
// DELETE /api/challenge/chain/:chainId/submissions
|
||
router.delete('/challenge/chain/:chainId/submissions', (req, res) => {
|
||
const chains = getChains();
|
||
const submissions = getSubmissions();
|
||
|
||
const chain = chains.find(c => c.id === req.params.chainId);
|
||
|
||
if (!chain) {
|
||
return respondError(res, 'Chain not found', 404);
|
||
}
|
||
|
||
// Get task IDs from chain
|
||
const taskIds = new Set(chain.tasks.map(t => t.id));
|
||
|
||
// Count and remove submissions for tasks in this chain
|
||
let deletedCount = 0;
|
||
for (let i = submissions.length - 1; i >= 0; i--) {
|
||
const sub = submissions[i];
|
||
const taskId = typeof sub.task === 'object' ? sub.task.id : sub.task;
|
||
|
||
if (taskIds.has(taskId)) {
|
||
submissions.splice(i, 1);
|
||
deletedCount++;
|
||
}
|
||
}
|
||
|
||
// Update stats
|
||
const stats = getStats();
|
||
stats.submissions.total = Math.max(0, stats.submissions.total - deletedCount);
|
||
|
||
respond(res, {
|
||
deletedCount: deletedCount,
|
||
chainId: chain.id
|
||
});
|
||
});
|
||
|
||
// ============= STATS =============
|
||
|
||
// GET /api/challenge/stats
|
||
router.get('/challenge/stats', (req, res) => {
|
||
const stats = getStats();
|
||
respond(res, stats);
|
||
});
|
||
|
||
// GET /api/challenge/stats/v2
|
||
router.get('/challenge/stats/v2', (req, res) => {
|
||
const statsV2 = getStatsV2WithUsers();
|
||
const chainId = req.query.chainId;
|
||
|
||
// Если chainId не передан, возвращаем все данные
|
||
if (!chainId) {
|
||
respond(res, statsV2);
|
||
return;
|
||
}
|
||
|
||
// Сначала проверяем наличие цепочки в chains.json
|
||
const chains = getChains();
|
||
const chain = chains.find(c => c.id === chainId);
|
||
|
||
if (!chain) {
|
||
return respondError(res, 'Chain not found', 404);
|
||
}
|
||
|
||
// Ищем данные цепочки в stats-v2.json
|
||
let filteredChain = statsV2.chainsDetailed.find(c => c.chainId === chainId);
|
||
|
||
// Если цепочка не найдена в stats-v2.json, создаем пустую структуру на основе chains.json
|
||
if (!filteredChain) {
|
||
filteredChain = {
|
||
chainId: chain.id,
|
||
name: chain.name,
|
||
totalTasks: chain.tasks.length,
|
||
tasks: chain.tasks.map(t => ({
|
||
taskId: t.id,
|
||
title: t.title,
|
||
description: t.description || ''
|
||
})),
|
||
participantProgress: []
|
||
};
|
||
}
|
||
|
||
// Фильтруем tasksTable - только задания из этой цепочки
|
||
const chainTaskIds = new Set(filteredChain.tasks.map(t => t.taskId));
|
||
const filteredTasksTable = statsV2.tasksTable.filter(t => chainTaskIds.has(t.taskId));
|
||
|
||
// Фильтруем activeParticipants - только участники с попытками в этой цепочке
|
||
const participantIds = new Set(filteredChain.participantProgress.map(p => p.userId));
|
||
const filteredParticipants = statsV2.activeParticipants
|
||
.filter(p => participantIds.has(p.userId))
|
||
.map(p => ({
|
||
...p,
|
||
chainProgress: p.chainProgress.filter(cp => cp.chainId === chainId)
|
||
}));
|
||
|
||
// Возвращаем отфильтрованные данные
|
||
respond(res, {
|
||
...statsV2,
|
||
tasksTable: filteredTasksTable,
|
||
activeParticipants: filteredParticipants,
|
||
chainsDetailed: [filteredChain]
|
||
});
|
||
});
|
||
|
||
// GET /api/challenge/user/:userId/stats
|
||
router.get('/challenge/user/:userId/stats', (req, res) => {
|
||
const users = getUsers();
|
||
const submissions = getSubmissions();
|
||
const chains = getChains();
|
||
|
||
const user = users.find(u => u.id === req.params.userId);
|
||
|
||
if (!user) {
|
||
return respondError(res, 'User not found', 404);
|
||
}
|
||
|
||
const userSubmissions = submissions.filter(s => s.user.id === req.params.userId);
|
||
|
||
// Calculate stats
|
||
const completedTasks = new Set();
|
||
const taskStats = {};
|
||
|
||
userSubmissions.forEach(sub => {
|
||
const taskId = sub.task.id;
|
||
|
||
if (!taskStats[taskId]) {
|
||
taskStats[taskId] = {
|
||
taskId: taskId,
|
||
taskTitle: sub.task.title,
|
||
attempts: [],
|
||
totalAttempts: 0,
|
||
status: 'not_attempted',
|
||
lastAttemptAt: null
|
||
};
|
||
}
|
||
|
||
taskStats[taskId].attempts.push({
|
||
attemptNumber: sub.attemptNumber,
|
||
status: sub.status,
|
||
submittedAt: sub.submittedAt,
|
||
checkedAt: sub.checkedAt,
|
||
feedback: sub.feedback
|
||
});
|
||
|
||
taskStats[taskId].totalAttempts++;
|
||
taskStats[taskId].status = sub.status;
|
||
taskStats[taskId].lastAttemptAt = sub.submittedAt;
|
||
|
||
if (sub.status === 'accepted') {
|
||
completedTasks.add(taskId);
|
||
}
|
||
});
|
||
|
||
const taskStatsArray = Object.values(taskStats);
|
||
|
||
// Chain stats
|
||
const chainStats = chains.map(chain => {
|
||
const completedInChain = chain.tasks.filter(t => completedTasks.has(t.id)).length;
|
||
return {
|
||
chainId: chain.id,
|
||
chainName: chain.name,
|
||
totalTasks: chain.tasks.length,
|
||
completedTasks: completedInChain,
|
||
progress: chain.tasks.length > 0 ? (completedInChain / chain.tasks.length * 100) : 0
|
||
};
|
||
});
|
||
|
||
const totalCheckTime = userSubmissions
|
||
.filter(s => s.checkedAt)
|
||
.reduce((sum, s) => {
|
||
const submitted = new Date(s.submittedAt).getTime();
|
||
const checked = new Date(s.checkedAt).getTime();
|
||
return sum + (checked - submitted);
|
||
}, 0);
|
||
|
||
const userStats = {
|
||
totalTasksAttempted: taskStatsArray.length,
|
||
completedTasks: completedTasks.size,
|
||
inProgressTasks: taskStatsArray.filter(t => t.status === 'in_progress').length,
|
||
needsRevisionTasks: taskStatsArray.filter(t => t.status === 'needs_revision').length,
|
||
totalSubmissions: userSubmissions.length,
|
||
averageCheckTimeMs: userSubmissions.length > 0 ? totalCheckTime / userSubmissions.length : 0,
|
||
taskStats: taskStatsArray,
|
||
chainStats: chainStats
|
||
};
|
||
|
||
respond(res, userStats);
|
||
});
|
||
|
||
// ============= SUBMISSIONS =============
|
||
|
||
// GET /api/challenge/user/:userId/submissions
|
||
router.get('/challenge/user/:userId/submissions', (req, res) => {
|
||
const submissions = getSubmissions();
|
||
const taskId = req.query.taskId;
|
||
|
||
let filtered = submissions.filter(s => s.user.id === req.params.userId);
|
||
|
||
if (taskId) {
|
||
filtered = filtered.filter(s => s.task.id === taskId);
|
||
}
|
||
|
||
respond(res, filtered);
|
||
});
|
||
|
||
// GET /api/challenge/chain/:chainId/submissions
|
||
router.get('/challenge/chain/:chainId/submissions', (req, res) => {
|
||
const chains = getChains();
|
||
const submissions = getSubmissions();
|
||
const users = getUsers();
|
||
|
||
const chainId = req.params.chainId;
|
||
const userId = req.query.userId;
|
||
const status = req.query.status;
|
||
const limit = parseInt(req.query.limit) || 100;
|
||
const offset = parseInt(req.query.offset) || 0;
|
||
|
||
// Найти цепочку
|
||
const chain = chains.find(c => c.id === chainId);
|
||
if (!chain) {
|
||
return respondError(res, 'Chain not found', 404);
|
||
}
|
||
|
||
// Получить taskIds из цепочки
|
||
const taskIds = new Set(chain.tasks.map(t => t.id));
|
||
|
||
// Фильтровать submissions по taskIds цепочки
|
||
let filteredSubmissions = submissions.filter(s => {
|
||
const taskId = typeof s.task === 'object' ? s.task.id : s.task;
|
||
return taskIds.has(taskId);
|
||
});
|
||
|
||
// Применить фильтр по userId если указан
|
||
if (userId) {
|
||
filteredSubmissions = filteredSubmissions.filter(s => {
|
||
const subUserId = typeof s.user === 'object' ? s.user.id : s.user;
|
||
return subUserId === userId;
|
||
});
|
||
}
|
||
|
||
// Применить фильтр по status если указан
|
||
if (status) {
|
||
filteredSubmissions = filteredSubmissions.filter(s => s.status === status);
|
||
}
|
||
|
||
// Получить уникальных участников
|
||
const participantMap = new Map();
|
||
|
||
filteredSubmissions.forEach(sub => {
|
||
const subUserId = typeof sub.user === 'object' ? sub.user.id : sub.user;
|
||
const subUserNickname = typeof sub.user === 'object' ? sub.user.nickname : '';
|
||
|
||
// Найти nickname если не заполнен
|
||
let nickname = subUserNickname;
|
||
if (!nickname) {
|
||
const user = users.find(u => u.id === subUserId);
|
||
nickname = user ? user.nickname : subUserId;
|
||
}
|
||
|
||
if (!participantMap.has(subUserId)) {
|
||
participantMap.set(subUserId, {
|
||
userId: subUserId,
|
||
nickname: nickname,
|
||
completedTasks: new Set(),
|
||
totalTasks: chain.tasks.length,
|
||
});
|
||
}
|
||
|
||
// Если статус accepted, добавляем taskId в completedTasks
|
||
if (sub.status === 'accepted') {
|
||
const taskId = typeof sub.task === 'object' ? sub.task.id : sub.task;
|
||
participantMap.get(subUserId).completedTasks.add(taskId);
|
||
}
|
||
});
|
||
|
||
// Преобразовать в массив и рассчитать прогресс
|
||
const participants = Array.from(participantMap.values()).map(p => ({
|
||
userId: p.userId,
|
||
nickname: p.nickname,
|
||
completedTasks: p.completedTasks.size,
|
||
totalTasks: p.totalTasks,
|
||
progressPercent: p.totalTasks > 0
|
||
? Math.round((p.completedTasks.size / p.totalTasks) * 100)
|
||
: 0,
|
||
}));
|
||
|
||
// Сортировать submissions по дате (новые сначала)
|
||
filteredSubmissions.sort((a, b) =>
|
||
new Date(b.submittedAt) - new Date(a.submittedAt)
|
||
);
|
||
|
||
// Применить пагинацию
|
||
const total = filteredSubmissions.length;
|
||
const paginatedSubmissions = filteredSubmissions.slice(offset, offset + limit);
|
||
|
||
// Формируем ответ
|
||
const response = {
|
||
chain: {
|
||
id: chain.id,
|
||
name: chain.name,
|
||
tasks: chain.tasks.map(t => ({
|
||
id: t.id,
|
||
title: t.title,
|
||
})),
|
||
},
|
||
participants: participants,
|
||
submissions: paginatedSubmissions,
|
||
pagination: {
|
||
total: total,
|
||
limit: limit,
|
||
offset: offset,
|
||
},
|
||
};
|
||
|
||
respond(res, response);
|
||
});
|
||
|
||
module.exports = router;
|