Тестовое задание для WIZICO TECH
Высокопроизводительное веб-приложение для совместной работы над задачами в реальном времени с поддержкой WebSocket, синхронизации присутствия, разрешения конфликтов, устойчивости к офлайну и ролевой модели доступа.
- Быстрый запуск (Quick Start)
- Архитектура и стек технологий
- Обоснование стратегий разрешения конфликтов
- Обработка Edge Cases и бизнес-правила
- Офлайн-устойчивость (Offline Resilience)
- Структура проекта
- Тестирование и верификация
Для запуска всего стека (Backend + Frontend + База данных) одной командой:
docker compose up --build- Frontend: http://localhost:3000
- Backend API: http://localhost:3001/api
- WebSocket Gateway:
ws://localhost:3001
cd backend
npm install
npm run db:setup # Генерирует Prisma client, накатывает схему и сидирует пользователей
npm run start:dev # Запуск на http://localhost:3001cd frontend
npm install
npm run dev # Запуск на http://localhost:3000В базе уже созданы 2 пользователя и совместный список задач "Team Launch Sprint":
| Пользователь | Пароль | Роль в списке | Цвет индикатора | |
|---|---|---|---|---|
| Alice (Admin) | alice@example.com |
password123 |
ADMIN (Владелец) | 🔵 Синий (#3B82F6) |
| Bob (Member) | bob@example.com |
password123 |
MEMBER (Участник) | 🟢 Зеленый (#10B981) |
💡 Удобное тестирование 2 пользователей:
- В интерфейсе приложения в шапке (Header) и на странице логина доступен 1-Click Fast Switcher, позволяющий мгновенно переключаться между Alice и Bob.
- Для проверки синхронизации в реальном времени откройте два окна браузера (например, обычное окно с Alice и окно инкогнито с Bob).
┌──────────────────────────────────────┐ WebSocket / Socket.IO ┌──────────────────────────────────────┐
│ Frontend (Next.js 14) │ ◄───────────────────────────────► │ Backend (NestJS 10) │
│ - App Router (React 18 + TS) │ │ - WebSocket Gateway (Socket.IO) │
│ - Tailwind CSS + Lucide Icons │ HTTP REST (Auth) │ - JWT Authentication Strategy │
│ - @hello-pangea/dnd (Drag & Drop) │ ────────────────────────────────► │ - Presence & Lock Service │
│ - Optimistic State + Offline Queue │ │ - Prisma ORM (SQLite / Postgres) │
└──────────────────────────────────────┘ └──────────────────────────────────────┘
- Backend: NestJS 10, TypeScript,
@nestjs/websockets,@nestjs/platform-socket.io,@nestjs/jwt,passport-jwt,bcryptjs,Prisma ORM. - Frontend: Next.js 14 (App Router), TypeScript, Tailwind CSS,
@hello-pangea/dnd,socket.io-client, Lucide Icons. - База данных: SQLite (по умолчанию для моментального локального запуска без внешних сервисов) / PostgreSQL-совместимая схема через Prisma.
| Стратегия | Плюсы | Минусы | Вердикт для Todo List |
|---|---|---|---|
| CRDT (Yjs, Automerge) | Посимвольное слияние без конфликтов | Высокая сложность, большой размер метаданных на каждый символ, избыточно для коротких заголовков | Избыточно |
| Operational Transformation (OT) | Точное посимвольное слияние (Google Docs) | Требует сложного централизованного сервера трансформаций, хрупкий протокол | Избыточно |
| Last-Write-Wins (LWW) без версионирования | Простота реализации | Тихая потеря данных (Silent Overwrite) одного из пользователей | Неприемлемо |
| Versioned Optimistic Concurrency + Non-destructive Merge UI (✅ Выбранная стратегия) | 100% гарантия от тихой потери данных, простота и масштабируемость, прозрачный UX | Требует UI-диалога/уведомления при редком прямом конфликте | Идеально |
- Каждая задача имеет целочисленный счетчик
version. - При отправке изменений клиент передает
expectedVersion. - Сервер атомарно сверяет версии:
- Если версии совпадают: версия инкрементируется (
version + 1), данные сохраняются и транслируются остальным участникам комнаты через событиеtask_updated. - Если обнаружена конкурентная модификация (версия на сервере изменилась): сервер возвращает статус
conflict: trueсо свежим состоянием задачи (serverTask).
- Если версии совпадают: версия инкрементируется (
- На клиенте открывается Conflict Resolution Modal, где пользователь видит обе версии (свою локальную и версию коллеги) и в 1 клик выбирает: «Принять версию коллеги» или «Оставить свою версию».
Если использовать целочисленные position: 0, 1, 2, ..., то одновременное перемещение двух элементов требует массового обновления (UPDATE ... WHERE position >= X), что приводит к race conditions, дедлокам и сбиванию порядка.
- Каждая задача хранит вещественный ранг
order: Float(например, 1000.0, 2000.0, 3000.0). - При перемещении задачи между двумя элементами с рангами
$A$ и$B$ , новый ранг вычисляется как среднее арифметическое:$$\text{newOrder} = \frac{A + B}{2}$$ - При перемещении в самый верх:
$\text{newOrder} = \frac{\text{nextOrder}}{2}$ . - При перемещении в самый низ:
$\text{newOrder} = \text{prevOrder} + 1000.0$ . - Результат: операция перемещения изолирована и обновляет только одну строку ($O(1)$) без необходимости блокировок таблицы и сдвига остальных задач.
- Presence-Aware Warning: Сервис
PresenceServiceотслеживает, на какой задаче сфокусирован каждый пользователь в реальном времени (activeTaskId). - Если Alice редактирует задачу, а Bob нажимает «Удалить»:
- Frontend / Backend перехватывают попытку и открывают модальное окно:
«Alice в данный момент редактирует эту задачу! Удаление приведет к потере ее изменений. Вы уверены, что хотите принудительно удалить?» - Если удаление подтверждено: у Alice задача плавно удаляется, а всплывающий Toast информирует: «Задача была удалена пользователем Bob».
- Frontend / Backend перехватывают попытку и открывают модальное окно:
Строго валидируются как на клиенте, так и на бэкенде в TasksService с возвратом типизированных ошибок:
| Правило | Реализация |
|---|---|
| Admin может удалить любую задачу | Проверяется роль в списке (ListMember.role === 'ADMIN'). Разрешено. |
| Member может удалить только свою задачу | Проверяется task.createdBy === userId. При попытке удалить чужую задачу сервер выбрасывает 403 Forbidden (MEMBER_NOT_CREATOR). |
| Нельзя удалить выполненную задачу | Если task.completed === true, обычному участнику удаление блокируется с требованием сначала снять отметку выполнения. |
| Admin может удалить выполненную задачу (с подтверждением) | Если task.completed === true и пользователь Admin, требуется явный флаг adminConfirmed: true. В UI автоматически открывается модальное окно подтверждения. |
- Индикация статуса: При разрыве соединения шапка приложения переключается в режим 🟡 Offline (N queued).
- Оптимистичные обновления (Optimistic UI): Пользователь может продолжать создавать, редактировать, отмечать, перетаскивать и удалять задачи локально.
- Очередь мутаций (
offlineQueue): Все действия сохраняются в локальную очередь (localStorage) с уникальными идентификаторамиclientMutationIdи временными метками. - Автоматическая синхронизация при восстановлении: При событии
connectклиент отправляет накопленный батч мутаций на шлюз (sync_offline_queue), сервер последовательно применяет изменения и возвращает синхронизированный снимок списка.
collaborative-todo/
├── backend/
│ ├── prisma/
│ │ ├── schema.prisma # Схема БД (User, List, ListMember, Task)
│ │ └── seed.ts # Сидирование Alice & Bob и стартового списка
│ ├── src/
│ │ ├── auth/ # JWT авторизация, Guard'ы, декораторы
│ │ ├── lists/ # Управление списками и приглашениями
│ │ ├── tasks/ # CRUD задач, fractional reorder, бизнес-валидации
│ │ ├── events/ # Socket.IO Gateway, Presence Service
│ │ ├── prisma/ # Prisma Module & Service
│ │ ├── app.module.ts
│ │ └── main.ts
│ ├── Dockerfile
│ └── package.json
│
├── frontend/
│ ├── src/
│ │ ├── app/
│ │ │ ├── page.tsx # Dashboard со списками задач
│ │ │ ├── login/page.tsx # Страница авторизации и 1-Click Switcher
│ │ │ ├── lists/[id]/page.tsx # Основная доска списка с Drag & Drop и Presence
│ │ │ └── join/[token]/page.tsx# Присоединение по инвайт-ссылке
│ │ ├── components/ # Header, TaskItem, PresenceBar, Modals, Toast
│ │ ├── hooks/ # useRealtimeTodoList, usePresence
│ │ ├── lib/ # API клиент, Socket клиент, Offline Queue
│ │ └── types/ # TypeScript интерфейсы
│ ├── Dockerfile
│ └── package.json
│
├── docker-compose.yml # Оркестрация контейнеров
└── README.md # Документация проекта
cd backend
npm run testВ backend/src/tasks/tasks.service.spec.ts реализованы тесты для всех бизнес-правил:
- Запрет обычному участнику удалять задачи, созданные другими пользователями (
ForbiddenException). - Разрешение участнику удалять свои задачи.
- Разрешение администратору удалять любые задачи.
- Блокировка удаления выполненных задач обычным участником.
- Требование подтверждения администратора при удалении выполненных задач.
- Вычисление дробных индексов (Fractional Indexing) при drag & drop.
- Детекция версионных конфликтов при конкурентном редактировании.