Initial commit: standalone quiz-testing system

Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL
persistence via Dapper, Docker Compose deployment. Student quiz-taking flow
with time-limit enforcement and focus-loss tracking, Teacher question bank
and quiz builder with results analytics, Admin user management.
This commit is contained in:
danamir
2026-08-06 12:36:16 +03:00
commit 942dfc9c1a
134 changed files with 10712 additions and 0 deletions

187
docs/PLAN.md Normal file
View File

@@ -0,0 +1,187 @@
# План реализации
Живой чек-лист по `docs/DESIGN.md`. Отмечайте пункты по мере реализации (`[ ]``[x]`); статусы
ниже соответствуют фактическому состоянию кода на 2026-08-03. Фазы и нумерация разделов совпадают
с `docs/DESIGN.md` §7 — там же обоснование каждого пункта, здесь только чек-лист.
## Базовое состояние на сегодня
- [x] Студенческий флоу целиком на **старой** доменной модели (`Course`/`CategoryId`/без
`Composition`) работает end-to-end через in-memory `Store`: `login``getAvailableQuizzes`
`startAttempt``submitAnswer``finishAttempt` — проверено в браузере.
- [x] CORS для dev настроен верно (`Client:Origin = http://localhost:5173`), баг с портом 5174 исправлен.
- [x] `Grading.applyGradingMethod` уже реализован и покрыт тестами (`HighestAttempt`, пустой список) —
но никуда не подключён (нет API-метода, который бы его вызывал).
- [x] `Attempt.isExpired` уже реализован — но нигде не вызывается, лимит времени сейчас не принуждается.
Все пункты ниже — то, чего в коде пока нет.
## Архитектурный рефакторинг: организация кода по вертикальным срезам
Отдельный от фаз 17 вопрос — не про функциональность, а про то, как раскладывать код по файлам
по мере роста Server/Client. Возник из обсуждения 2026-08-03: сейчас архитектура классическая
слоистая (3 проекта = 3 слоя), а не по фиче.
**Текущее состояние.** `Domain` (чистые типы и бизнес-логика) → `Server` (`Store.fs` + `Auth.fs` +
один `QuizApi.fs` на все хендлеры, роутинг через Fable.Remoting.Giraffe по единому интерфейсу
`IQuizApi`) → `Client` (Elmish MVU: один общий `Model` в `Types.fs`, один общий `Msg`-DU, один
`update` в `State.fs`, один `View.fs`). Срез идёт по техническому слою (типы / состояние /
отображение / API), а не по фиче — ровно то, что архитектура вертикальных срезов (VSA) устраняет.
**Почему не полный переход на VSA.** Два места будут этому сопротивляться:
1. Контракт `IQuizApi`/`ITeacherApi`/`IAdminApi` в `Domain.Contracts` (DESIGN.md §4) — это по сути
RPC-интерфейс "один record на роль", а не роутинг по фиче. Fable.Remoting-style клиент
(`Api.fs`) уже завязан на паттерн `/api/<TypeName>/<Method>` по этим record'ам.
2. Elmish с одним `Model`/`Msg` на всё приложение — тоже принципиально центральный паттерн;
срез по фиче потребовал бы отдельного рефакторинга на суб-модели (паттерн "Elmish page"),
не связанного с серверным вопросом.
**Компромиссное решение.** Не ломать контракт и Elmish целиком, а **реализацию** каждого метода
интерфейса выносить в свой файл/папку по фиче, а не копить всё в одном `TeacherApi.fs`/`View.fs`.
Даёт большую часть пользы VSA (не лазить по всему файлу ради одной фичи, фича = один файл со всем
необходимым) без переписывания транспорта и стейт-менеджмента.
**Почему сейчас удобный момент.** Из нового дизайна пока не реализовано практически ничего (см.
«Базовое состояние» выше) — так что переход дешевле всего до того, как `TeacherApi.fs`/`View.fs`
разрастутся под Teacher/Admin функциональность.
### Server: организация по фиче
- [ ] Вместо одного `TeacherApi.fs`/`AdminApi.fs` — папка `Server/Features/<Role>/<UseCase>.fs`
(например `Server/Features/Teacher/CreateQuiz.fs`, `.../PublishQuiz.fs`), каждый файл содержит
маппинг запроса/ответа и логику ровно одного метода интерфейса.
- [ ] Сборка `ITeacherApi`/`IAdminApi` в одном месте (аналог текущего `QuizApi.build`) остаётся
тонкой композицией — record просто ссылается на функции из `Features/*`, сам не содержит логики.
- [ ] Тот же принцип для уже существующего `QuizApi.fs` — постепенно разнести на
`Server/Features/Student/*.fs`, но не отдельным рывком, а по мере следующих правок этого файла.
### Client: частичная декомпозиция Elmish
- [ ] Общий `Model`/`Msg`/`update` в `Types.fs`/`State.fs` остаётся корнем (сессия, роутинг между
страницами), но крупные разделы (кабинет Teacher, кабинет Admin) выносятся в под-модели по
паттерну "Elmish page" — свои Model/Msg/update/view на страницу — вместо бесконечного
расширения единых `Types.fs`/`State.fs`/`View.fs`.
- [ ] Не переписывать существующий студенческий флоу (`Types.fs`/`State.fs`/`View.fs`) ради этого —
он маленький и рабочий; применять паттерн к новым разделам (Teacher/Admin UI, фазы 34).
### Когда применять
- [ ] Не блокирует фазы 12 (домен/БД) — там организация по типу файла (`Users.fs`/`Quizzes.fs`/...,
`Store.fs`) уже естественна и менять её не нужно.
- [ ] Применяется как соглашение **начиная с фазы 3** (Admin API) — новый код сразу пишется в
`Features/`-структуре; уже написанный `QuizApi.fs` не рефакторится превентивно, только когда
до него дойдёт очередная правка (рефакторинг не ради рефакторинга).
## Фаза 1 — Домен (DESIGN.md §3)
### 1.1 Пользователь (§3.1)
- [ ] `User.IsActive`
- [ ] `User.CreatedAt`
- [ ] `login` проверяет `IsActive`, отказывает тем же текстом ошибки, что и неверный пароль
### 1.2 Темы и вопросы (§3.2)
- [ ] `QuestionCategory``Topic`, `CategoryId``TopicId`
- [ ] `Topic.OwnerId` (вместо `CourseId`), `Topic.CreatedAt`
- [ ] `Question.TopicId` (вместо `CategoryId`)
- [ ] `Question.IsArchived`, `Question.CreatedAt`
- [ ] Правило мягкого удаления вопроса (архивировать вместо удаления, если используется) — сама
флаг-логика в домене; серверная проверка "используется ли где-то" — фаза 4
### 1.3 Состав теста (§3.3)
- [ ] `RandomTopicRule`
- [ ] `QuizComposition` (`FixedQuestions` / `RandomFromTopics`)
- [ ] `Quiz.OwnerId` (вместо `CourseId`)
- [ ] `Quiz.AssignedStudentIds`
- [ ] `Quiz.IsPublished`, `Quiz.IsArchived`, `Quiz.CreatedAt`
- [ ] `Quiz.totalPoints` пересчитан под `Composition` (сумма по `FixedQuestions` или
`Count * PointsPerQuestion` по `RandomFromTopics`)
- [ ] Удалены `Course`, `Enrollment`, `CourseId`, `EnrollmentRole`
### 1.4 Резолв и попытка (§3.4, §3.6)
- [ ] `AttemptFinishReason` (`ManualSubmit` / `TimedOut`)
- [ ] `Attempt.AttemptNumber`
- [ ] `Attempt.FinishReason`
- [ ] `Attempt.ResolvedQuestions`
- [ ] `Attempt.FocusLossCount`
- [ ] `Quiz.resolveComposition` (чистая функция, shuffle и вопросы темы — параметрами)
- [ ] `Attempt.submit` принимает `AttemptFinishReason`
- [ ] `Grading.gradeAttempt` переключён на `attempt.ResolvedQuestions` вместо `quiz.Questions`
- [ ] Проверка `Attempt.isExpired` встроена в поток `submitAnswer`/`finishAttempt` (авто-завершение
с `FinishReason = TimedOut`)
### 1.5 Типы аналитики и валидация (§3.5, §3.7)
- [ ] `QuestionStat`
- [ ] `AttemptSummary`, `AttemptDetail`, `StudentQuizResult`
- [ ] `Validation.fs`: `QuizValidation` переработан под `QuizComposition` (сейчас требует
непустой `quiz.Questions`, нужно — непустой `FixedQuestions` **или** хотя бы одно правило
с `Count > 0` в `RandomFromTopics`)
### 1.6 Юнит-тесты (`tests/Domain.Tests`)
- [ ] `resolveComposition`: `FixedQuestions` (с шаффлом и без), `RandomFromTopics` (успешный набор,
ошибка при нехватке вопросов в теме)
- [ ] `totalPoints` для обоих режимов `Composition`
- [ ] `applyGradingMethod`: добавить `AverageAttempt`/`FirstAttempt`/`LastAttempt` (сейчас покрыт
только `HighestAttempt`)
- [ ] Существующие `GradingTests.fs`/`ValidationTests.fs` обновлены под новую форму
`Question`/`Quiz` (`TopicId` вместо `CategoryId`, `Composition` вместо `Questions`)
## Фаза 2 — PostgreSQL (§5)
- [ ] SQL-схема: `users`, `topics`, `questions`, `quizzes`, `quiz_assignments`, `attempts`, `attempt_grades`
- [ ] Миграции (DbUp, пронумерованные `.sql`, применяются при старте сервера)
- [ ] Dapper-репозиторий взамен `Store.fs` (тот же member-интерфейс)
- [ ] Строка подключения к Postgres в конфиге (`appsettings.*.json`)
- [ ] `IHostedService` — "expiry sweeper" для заброшенных просроченных попыток (§3.6)
## Фаза 3 — Admin API + UI (§2, §4.3)
- [ ] `IAdminApi.listUsers`
- [ ] `IAdminApi.createUser` (с выбором `Role`)
- [ ] `IAdminApi.updateUser`
- [ ] `IAdminApi.deactivateUser`
- [ ] `IAdminApi.resetPassword`
- [ ] Проверка роли `Admin` на сервере для всех методов `IAdminApi`
- [ ] UI: список пользователей, создание/деактивация/сброс пароля
## Фаза 4 — Teacher API + UI (§4.2)
- [ ] `ITeacherApi`: `listTopics`/`createTopic`/`renameTopic`/`deleteTopic`
- [ ] `ITeacherApi`: `listQuestions`/`createQuestion`/`updateQuestion`/`deleteQuestion`
(с архивированием вместо удаления, если вопрос используется — §3.2)
- [ ] `ITeacherApi`: `listMyQuizzes`/`getQuiz`/`createQuiz`/`updateQuiz`
- [ ] `ITeacherApi`: `publishQuiz`/`unpublishQuiz`
- [ ] `ITeacherApi`: `deleteQuiz` (архивирование, если есть попытки — §3.3)
- [ ] `ITeacherApi`: `listStudents`/`assignStudents`/`unassignStudent`
- [ ] Проверка владения (`OwnerId`) на каждом хендлере, кроме `listStudents`
- [ ] UI: темы → вопросы → конструктор теста (fixed-список / random-по-темам) → назначение студентов
## Фаза 5 — Результаты и аналитика (§3.5, §3.7, §4.2)
- [ ] Запись в `attempt_grades` при `finishAttempt`/авто-завершении
- [ ] `ITeacherApi.getQuizAttempts`
- [ ] `ITeacherApi.getAttemptDetail`
- [ ] `ITeacherApi.getQuizResults` (сводка по студентам с учётом `GradingMethod`)
- [ ] `ITeacherApi.getQuestionStats`
- [ ] UI: список попыток (с `FocusLossCount`/`FinishReason`/длительностью), карточка попытки,
сводная ведомость по студентам, аналитика по вопросам
## Фаза 6 — Student UI (§4.1, §6)
- [ ] `getAvailableQuizzes`: фильтр по `IsPublished`, `AssignedStudentIds`, `not IsArchived`, окну дат
- [ ] `IQuizApi.getMyResults` + экран истории попыток студента
- [ ] `IQuizApi.reportFocusLoss` + клиентские слушатели `visibilitychange`/`blur`
- [ ] Обратный отсчёт времени в UI прохождения теста
- [ ] Кнопка «Завершить тест» вынесена в отдельный зафиксированный блок, не участвующий в
скролле списка вопросов (§6)
## Фаза 7 — Деплой на RuVDS (§1, §7)
- [ ] systemd-юнит для Kestrel
- [ ] nginx как reverse proxy + TLS
- [ ] Прод-конфиг: `Jwt:Secret`, `Client:Origin`, строка подключения к Postgres
(сейчас в `appsettings.Development.json` — dev-значения, включая исправленный порт 5173)
## Открытые вопросы (DESIGN.md §8) — решить по ходу соответствующей фазы
- [ ] Нужен ли предпросмотр/тестовый прогон теста преподавателем без сохранения попытки в статистику? (фаза 4)
- [ ] Показывать ли студенту его ответы с правильными при просмотре результата, или только баллы? (фаза 5/6)
- [ ] Валидировать нехватку вопросов в теме для `RandomFromTopics` при создании теста или только при старте попытки? (фаза 1/4)
- [ ] Нужен ли визуальный порог/бейдж «подозрительно» при большом `FocusLossCount`? (фаза 5)
- [ ] Показывать ли преподавателю архивные вопросы/тесты в общих списках (приглушённо+фильтр) или полностью прятать? (фаза 4)