6 Commits

14 changed files with 683 additions and 11 deletions

582
WORKPLACE_NUMBER_API.md Normal file
View File

@@ -0,0 +1,582 @@
# API изменения: Поле workplaceNumber
## Обзор
Добавлено новое поле `workplaceNumber` для отслеживания рабочего места (компьютера), за которым работает ученик. Это поле сохраняется при авторизации и возвращается во всех эндпоинтах статистики.
---
## 1. Авторизация пользователя
### `POST /challenge/auth`
Регистрация или авторизация пользователя с указанием рабочего места.
#### Изменения
- ✨ Добавлен опциональный параметр `workplaceNumber`
- При создании нового пользователя сохраняется `workplaceNumber`
- При повторной авторизации существующего пользователя с другим `workplaceNumber` - значение обновляется
- Поиск пользователя по-прежнему выполняется только по `nickname`
#### Request
```http
POST /challenge/auth
Content-Type: application/json
{
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // Опционально
}
```
#### Request Body Parameters
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `nickname` | `string` | ✅ Да | Никнейм пользователя (3-50 символов) |
| `workplaceNumber` | `string` | ❌ Нет | Номер рабочего места/компьютера (макс. 50 символов) |
#### Response
```json
{
"error": null,
"result": {
"ok": true,
"userId": "507f1f77bcf86cd799439011"
}
}
```
#### Примеры использования
**Первая авторизация с рабочим местом:**
```javascript
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15'
})
});
```
**Повторная авторизация с другого места:**
```javascript
// Если пользователь пересел за другой компьютер
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-20' // Обновится в базе
})
});
```
**Авторизация без указания места:**
```javascript
// Работает как раньше, workplaceNumber необязателен
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan'
})
});
```
---
## 2. Статистика по цепочке заданий
### `GET /challenge/chain/:chainId/submissions`
Получение всех попыток по цепочке с данными о рабочих местах участников.
**Требует права:** `teacher` или `challenge-author`
#### Изменения
-В объекте `user` внутри `submissions` добавлено поле `workplaceNumber`
-В массиве `participants` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/chain/507f1f77bcf86cd799439011/submissions?limit=50&offset=0
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `userId` | `string` | Фильтр по конкретному пользователю |
| `status` | `string` | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` |
| `limit` | `number` | Количество записей (по умолчанию: 100) |
| `offset` | `number` | Смещение для пагинации (по умолчанию: 0) |
#### Response
```json
{
"error": null,
"result": {
"chain": {
"id": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"tasks": [
{
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
}
]
},
"participants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"completedTasks": 5,
"totalTasks": 10,
"progressPercent": 50
},
{
"userId": "507f1f77bcf86cd799439014",
"nickname": "student_maria",
"workplaceNumber": "PC-20", // ✨ Новое поле
"completedTasks": 8,
"totalTasks": 10,
"progressPercent": 80
}
],
"submissions": [
{
"id": "507f1f77bcf86cd799439015",
"user": {
"id": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // ✨ Новое поле
},
"task": {
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
},
"status": "accepted",
"attemptNumber": 2,
"submittedAt": "2024-01-15T10:30:00.000Z",
"checkedAt": "2024-01-15T10:31:23.000Z",
"feedback": "Отличная работа!"
}
],
"pagination": {
"total": 150,
"limit": 50,
"offset": 0
}
}
}
```
#### Пример использования
```javascript
const chainId = '507f1f77bcf86cd799439011';
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': 'Bearer YOUR_TOKEN' // Требуется токен преподавателя
}
});
const data = await response.json();
// Отобразить список участников с их местами
data.result.participants.forEach(participant => {
console.log(`${participant.nickname} (${participant.workplaceNumber}): ${participant.progressPercent}%`);
// Вывод: "student_ivan (PC-15): 50%"
});
```
---
## 3. Расширенная статистика системы
### `GET /challenge/stats/v2`
Получение детальной статистики с данными о рабочих местах участников.
#### Изменения
-В массиве `activeParticipants` добавлено поле `workplaceNumber`
-В `chainsDetailed[].participantProgress[]` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/stats/v2
```
или с фильтром по конкретной цепочке:
```http
GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439011
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `chainId` | `string` | Опционально: фильтр по конкретной цепочке |
#### Response (фрагмент)
```json
{
"error": null,
"result": {
"users": 25,
"tasks": 50,
"chains": 5,
"submissions": {
"total": 342,
"accepted": 150,
"rejected": 80,
"pending": 12,
"inProgress": 100
},
"averageCheckTimeMs": 2500,
"queue": {
"pending": 5,
"processing": 2,
"completed": 335
},
"tasksTable": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"totalAttempts": 45,
"uniqueUsers": 20,
"acceptedCount": 18,
"successRate": 90,
"averageAttemptsToSuccess": 2.1
}
],
"activeParticipants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"totalSubmissions": 25,
"completedTasks": 12,
"chainProgress": [
{
"chainId": "507f1f77bcf86cd799439011",
"chainName": "Основы Python",
"totalTasks": 10,
"completedTasks": 8,
"progressPercent": 80
}
]
}
],
"chainsDetailed": [
{
"chainId": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"totalTasks": 10,
"tasks": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"description": "Изучите основные типы данных..."
}
],
"participantProgress": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"taskProgress": [
{
"taskId": "507f1f77bcf86cd799439012",
"taskTitle": "Переменные и типы данных",
"status": "completed"
}
],
"completedCount": 8,
"progressPercent": 80
}
]
}
]
}
}
```
#### Пример использования
```javascript
const response = await fetch('/challenge/stats/v2');
const data = await response.json();
// Создать карту класса с прогрессом
const classMap = data.result.activeParticipants.map(participant => ({
workplace: participant.workplaceNumber || 'Не указано',
student: participant.nickname,
progress: participant.completedTasks,
chains: participant.chainProgress
}));
// Отсортировать по номеру места
classMap.sort((a, b) => {
const numA = parseInt(a.workplace.replace(/\D/g, '')) || 0;
const numB = parseInt(b.workplace.replace(/\D/g, '')) || 0;
return numA - numB;
});
// Визуализация карты класса
classMap.forEach(item => {
console.log(`[${item.workplace}] ${item.student}: ${item.progress} заданий`);
});
// Вывод:
// [PC-15] student_ivan: 12 заданий
// [PC-20] student_maria: 15 заданий
```
---
## Примеры интеграции на фронтенде
### Компонент авторизации (React)
```jsx
import { useState } from 'react';
function LoginForm() {
const [nickname, setNickname] = useState('');
const [workplaceNumber, setWorkplaceNumber] = useState('');
const handleLogin = async (e) => {
e.preventDefault();
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname,
workplaceNumber: workplaceNumber || undefined
})
});
const data = await response.json();
if (data.result.ok) {
localStorage.setItem('userId', data.result.userId);
localStorage.setItem('nickname', nickname);
localStorage.setItem('workplaceNumber', workplaceNumber);
// Перенаправление на главную страницу
}
};
return (
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Никнейм"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
/>
<input
type="text"
placeholder="Номер компьютера (опционально)"
value={workplaceNumber}
onChange={(e) => setWorkplaceNumber(e.target.value)}
/>
<button type="submit">Войти</button>
</form>
);
}
```
### Отображение карты класса (React)
```jsx
function ClassroomMap({ chainId }) {
const [participants, setParticipants] = useState([]);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await response.json();
setParticipants(data.result.participants);
};
fetchData();
}, [chainId]);
return (
<div className="classroom-map">
<h2>Карта класса</h2>
<div className="grid">
{participants.map(participant => (
<div
key={participant.userId}
className="student-card"
>
<div className="workplace-badge">
{participant.workplaceNumber || 'N/A'}
</div>
<div className="student-info">
<strong>{participant.nickname}</strong>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${participant.progressPercent}%` }}
/>
</div>
<small>
{participant.completedTasks} / {participant.totalTasks} заданий
</small>
</div>
</div>
))}
</div>
</div>
);
}
```
### TypeScript интерфейсы
```typescript
// Типы для работы с новым API
interface AuthRequest {
nickname: string;
workplaceNumber?: string;
}
interface AuthResponse {
ok: boolean;
userId: string;
}
interface UserInfo {
id: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
}
interface Participant {
userId: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
completedTasks: number;
totalTasks: number;
progressPercent: number;
}
interface Submission {
id: string;
user: UserInfo; // Содержит workplaceNumber
task: {
id: string;
title: string;
};
status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
attemptNumber: number;
submittedAt: string;
checkedAt?: string;
feedback?: string;
}
interface ChainSubmissionsResponse {
chain: {
id: string;
name: string;
tasks: Array<{ id: string; title: string }>;
};
participants: Participant[];
submissions: Submission[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
```
---
## Миграция существующего кода
### До (без workplaceNumber)
```javascript
// Старый код авторизации
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({ nickname: 'student_ivan' })
});
// Старое отображение участников
participants.forEach(p => {
console.log(`${p.nickname}: ${p.progressPercent}%`);
});
```
### После (с workplaceNumber)
```javascript
// Новый код авторизации с местом
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15' // ✨ Добавлено
})
});
// Новое отображение участников
participants.forEach(p => {
const workplace = p.workplaceNumber ? `[${p.workplaceNumber}] ` : '';
console.log(`${workplace}${p.nickname}: ${p.progressPercent}%`);
// Вывод: "[PC-15] student_ivan: 50%"
});
```
---
## Обратная совместимость
**Все изменения обратно совместимы:**
- Поле `workplaceNumber` опционально при авторизации
- Старый код без `workplaceNumber` продолжит работать
- Если `workplaceNumber` не указан, в ответах будет `undefined`
- Поиск пользователей по-прежнему работает только по `nickname`
---
## Рекомендации
1. **При авторизации**: Всегда передавайте `workplaceNumber`, если он известен (например, определяйте автоматически по IP или позволяйте ученику выбрать)
2. **В UI**: Отображайте номер места рядом с именем ученика для удобства преподавателя
3. **Сортировка**: При отображении списка учеников сортируйте по `workplaceNumber` для соответствия физическому расположению
4. **Валидация**: Проверяйте формат `workplaceNumber` на фронте (например, "PC-01", "Место 15")
5. **Обновление**: Если ученик пересел, просто авторизуйтесь с новым `workplaceNumber` - значение автоматически обновится
---
## Вопросы и поддержка
При возникновении вопросов обращайтесь к бэкенд-команде или создавайте issue в репозитории проекта.

View File

@@ -23,6 +23,7 @@ module.exports = {
features: {
'challenge-admin': {
'use-chain-submissions-api': { value: 'true' },
'submissions-polling-interval-ms': { value: '1200' },
},
},
config: {

View File

@@ -158,6 +158,7 @@
"challenge.admin.users.empty.description": "Users will appear after registration",
"challenge.admin.users.search.empty": "Nothing found for \"{query}\"",
"challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Workplace",
"challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Registration date",
"challenge.admin.users.table.actions": "Actions",
@@ -210,6 +211,7 @@
"challenge.admin.submissions.search.empty.title": "Nothing found",
"challenge.admin.submissions.search.empty.description": "Try changing filters",
"challenge.admin.submissions.table.user": "User",
"challenge.admin.submissions.table.workplace": "Workplace",
"challenge.admin.submissions.table.task": "Task",
"challenge.admin.submissions.table.status": "Status",
"challenge.admin.submissions.table.attempt": "Attempt",

View File

@@ -157,6 +157,7 @@
"challenge.admin.users.empty.description": "Пользователи появятся после регистрации",
"challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено",
"challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Место",
"challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Дата регистрации",
"challenge.admin.users.table.actions": "Действия",
@@ -209,6 +210,7 @@
"challenge.admin.submissions.search.empty.title": "Ничего не найдено",
"challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры",
"challenge.admin.submissions.table.user": "Пользователь",
"challenge.admin.submissions.table.workplace": "Место",
"challenge.admin.submissions.table.task": "Задание",
"challenge.admin.submissions.table.status": "Статус",
"challenge.admin.submissions.table.attempt": "Попытка",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "challenge-admin-pl",
"version": "1.3.1",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "challenge-admin-pl",
"version": "1.3.1",
"version": "1.5.1",
"license": "ISC",
"dependencies": {
"@brojs/cli": "^1.9.4",

View File

@@ -1,6 +1,6 @@
{
"name": "challenge-admin",
"version": "1.3.1",
"version": "1.5.1",
"description": "",
"main": "./src/index.tsx",
"scripts": {

View File

@@ -49,6 +49,11 @@ export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ part
<VStack align="stretch" gap={3}>
{/* Participant Header */}
<Box>
{participant.workplaceNumber && (
<Text fontSize="xs" color="gray.500" mb={1}>
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="lg" fontWeight="bold" color="teal.700">
{participant.nickname}
</Text>

View File

@@ -41,9 +41,18 @@ export const SubmissionsPage: React.FC = () => {
const navigate = useNavigate()
const { chainId } = useParams<{ chainId?: string }>()
// Проверяем feature flag
// Проверяем feature flags
const featureValue = getFeatureValue('challenge-admin', 'use-chain-submissions-api')
const useNewApi = featureValue?.value === 'true'
const pollingIntervalFeatureValue = getFeatureValue(
'challenge-admin',
'submissions-polling-interval-ms'
)
const pollingIntervalMs = (() => {
const rawValue = pollingIntervalFeatureValue?.value ?? ''
const parsed = Number.parseInt(rawValue, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1200
})()
// Состояние для выбранного пользователя и фильтров
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
@@ -70,7 +79,10 @@ export const SubmissionsPage: React.FC = () => {
userId: selectedUserId || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
},
{ skip: !chainId || !useNewApi }
{
skip: !chainId || !useNewApi,
pollingInterval: pollingIntervalMs,
}
)
// Старый API: получаем общую статистику и submissions отдельно
@@ -470,9 +482,16 @@ export const SubmissionsPage: React.FC = () => {
transition="all 0.2s"
>
<HStack justify="space-between" mb={2} gap={2}>
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
{participant.nickname}
</Text>
<VStack align="start" gap={0}>
{participant.workplaceNumber && (
<Text fontSize="xs" color="gray.500">
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
{participant.nickname}
</Text>
</VStack>
<Badge colorPalette={colorPalette} size="sm">
{participant.progressPercent}%
</Badge>
@@ -508,6 +527,7 @@ export const SubmissionsPage: React.FC = () => {
<Table.Header>
<Table.Row>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.user')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.workplace')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.task')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.status')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.attempt')}</Table.ColumnHeader>
@@ -530,6 +550,11 @@ export const SubmissionsPage: React.FC = () => {
? rawUser
: ''
const workplaceNumber =
rawUser && typeof rawUser === 'object' && 'workplaceNumber' in rawUser
? rawUser.workplaceNumber ?? ''
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
@@ -540,6 +565,11 @@ export const SubmissionsPage: React.FC = () => {
return (
<Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell>{title}</Table.Cell>
<Table.Cell>
<StatusBadge status={submission.status} />

View File

@@ -61,6 +61,7 @@ export const UsersPage: React.FC = () => {
<Table.Header>
<Table.Row>
<Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.table.workplace')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.total.submissions')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader>
@@ -71,6 +72,11 @@ export const UsersPage: React.FC = () => {
{filteredUsers.map((user) => (
<Table.Row key={user.userId}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{user.workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
{user.userId}

View File

@@ -4,6 +4,7 @@ export interface ChallengeUser {
_id: string
id: string
nickname: string
workplaceNumber?: string
createdAt: string
}
@@ -180,6 +181,7 @@ export interface ChainProgress {
export interface ActiveParticipant {
userId: string
nickname: string
workplaceNumber?: string
totalSubmissions: number
completedTasks: number
chainProgress: ChainProgress[]
@@ -194,6 +196,7 @@ export interface TaskProgress {
export interface ParticipantProgress {
userId: string
nickname: string
workplaceNumber?: string
taskProgress: TaskProgress[]
completedCount: number
progressPercent: number
@@ -262,6 +265,7 @@ export interface TestSubmissionResult {
export interface ChainSubmissionsParticipant {
userId: string
nickname: string
workplaceNumber?: string
completedTasks: number
totalTasks: number
progressPercent: number

View File

@@ -203,6 +203,7 @@
{
"userId": "6909b51512c75d75a36a52bf",
"nickname": "Примаков А.А.",
"workplaceNumber": "PC-07",
"totalSubmissions": 14,
"completedTasks": 1,
"chainProgress": [
@@ -225,6 +226,7 @@
{
"userId": "user_1",
"nickname": "alex_dev",
"workplaceNumber": "PC-01",
"totalSubmissions": 18,
"completedTasks": 12,
"chainProgress": [
@@ -247,6 +249,7 @@
{
"userId": "user_2",
"nickname": "maria_coder",
"workplaceNumber": "PC-05",
"totalSubmissions": 15,
"completedTasks": 9,
"chainProgress": [
@@ -269,6 +272,7 @@
{
"userId": "user_3",
"nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"totalSubmissions": 10,
"completedTasks": 5,
"chainProgress": [
@@ -291,6 +295,7 @@
{
"userId": "user_4",
"nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"totalSubmissions": 22,
"completedTasks": 15,
"chainProgress": [
@@ -313,6 +318,7 @@
{
"userId": "user_5",
"nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"totalSubmissions": 12,
"completedTasks": 6,
"chainProgress": [
@@ -335,6 +341,7 @@
{
"userId": "user_6",
"nickname": "anna_react",
"workplaceNumber": "PC-08",
"totalSubmissions": 14,
"completedTasks": 7,
"chainProgress": [
@@ -376,6 +383,7 @@
{
"userId": "user_1",
"nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -394,6 +402,7 @@
{
"userId": "user_2",
"nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -412,6 +421,7 @@
{
"userId": "user_3",
"nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -430,6 +440,7 @@
{
"userId": "user_4",
"nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -448,6 +459,7 @@
{
"userId": "user_5",
"nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -466,6 +478,7 @@
{
"userId": "user_6",
"nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" },
@@ -503,6 +516,7 @@
{
"userId": "user_1",
"nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -521,6 +535,7 @@
{
"userId": "user_2",
"nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -539,6 +554,7 @@
{
"userId": "user_3",
"nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -557,6 +573,7 @@
{
"userId": "user_4",
"nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -575,6 +592,7 @@
{
"userId": "user_5",
"nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" },
@@ -593,6 +611,7 @@
{
"userId": "user_6",
"nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },

View File

@@ -6,6 +6,7 @@
"_id": "user001",
"id": "user001",
"nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z"
},
"task": {
@@ -31,6 +32,7 @@
"_id": "user001",
"id": "user001",
"nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z"
},
"task": {
@@ -56,6 +58,7 @@
"_id": "user002",
"id": "user002",
"nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z"
},
"task": {
@@ -81,6 +84,7 @@
"_id": "user003",
"id": "user003",
"nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z"
},
"task": {
@@ -106,6 +110,7 @@
"_id": "user004",
"id": "user004",
"nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z"
},
"task": {
@@ -131,6 +136,7 @@
"_id": "user005",
"id": "user005",
"nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z"
},
"task": {
@@ -156,6 +162,7 @@
"_id": "user006",
"id": "user006",
"nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z"
},
"task": {

View File

@@ -3,36 +3,42 @@
"_id": "user001",
"id": "user001",
"nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z"
},
{
"_id": "user002",
"id": "user002",
"nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z"
},
{
"_id": "user003",
"id": "user003",
"nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z"
},
{
"_id": "user004",
"id": "user004",
"nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z"
},
{
"_id": "user005",
"id": "user005",
"nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z"
},
{
"_id": "user006",
"id": "user006",
"nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z"
},
{
@@ -45,6 +51,7 @@
"_id": "user008",
"id": "user008",
"nickname": "elena_fullstack",
"workplaceNumber": "PC-20",
"createdAt": "2024-10-28T10:00:00.000Z"
}
]

View File

@@ -604,18 +604,24 @@ router.get('/challenge/chain/:chainId/submissions', (req, res) => {
filteredSubmissions.forEach(sub => {
const subUserId = typeof sub.user === 'object' ? sub.user.id : sub.user;
const subUserNickname = typeof sub.user === 'object' ? sub.user.nickname : '';
const subUserWorkplaceNumber = typeof sub.user === 'object' ? sub.user.workplaceNumber : undefined;
// Найти nickname если не заполнен
// Найти nickname и workplaceNumber если не заполнены
let nickname = subUserNickname;
if (!nickname) {
let workplaceNumber = subUserWorkplaceNumber;
if (!nickname || !workplaceNumber) {
const user = users.find(u => u.id === subUserId);
nickname = user ? user.nickname : subUserId;
if (user) {
nickname = nickname || user.nickname || subUserId;
workplaceNumber = workplaceNumber || user.workplaceNumber;
}
}
if (!participantMap.has(subUserId)) {
participantMap.set(subUserId, {
userId: subUserId,
nickname: nickname,
workplaceNumber: workplaceNumber,
completedTasks: new Set(),
totalTasks: chain.tasks.length,
});
@@ -632,6 +638,7 @@ router.get('/challenge/chain/:chainId/submissions', (req, res) => {
const participants = Array.from(participantMap.values()).map(p => ({
userId: p.userId,
nickname: p.nickname,
workplaceNumber: p.workplaceNumber,
completedTasks: p.completedTasks.size,
totalTasks: p.totalTasks,
progressPercent: p.totalTasks > 0