Files
challenge-admin-pl/stubs/api/index.js
T

577 lines
16 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 });
});
// ============= 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;
}
// Фильтруем данные по выбранной цепочке
const filteredChain = statsV2.chainsDetailed.find(c => c.chainId === chainId);
if (!filteredChain) {
return respondError(res, 'Chain not found', 404);
}
// Фильтруем 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;