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.
15 KiB
План реализации
Живой чек-лист по docs/DESIGN.md. Отмечайте пункты по мере реализации ([ ] → [x]); статусы
ниже соответствуют фактическому состоянию кода на 2026-08-03. Фазы и нумерация разделов совпадают
с docs/DESIGN.md §7 — там же обоснование каждого пункта, здесь только чек-лист.
Базовое состояние на сегодня
- Студенческий флоу целиком на старой доменной модели (
Course/CategoryId/безComposition) работает end-to-end через in-memoryStore:login→getAvailableQuizzes→startAttempt→submitAnswer→finishAttempt— проверено в браузере. - CORS для dev настроен верно (
Client:Origin = http://localhost:5173), баг с портом 5174 исправлен. Grading.applyGradingMethodуже реализован и покрыт тестами (HighestAttempt, пустой список) — но никуда не подключён (нет API-метода, который бы его вызывал).Attempt.isExpiredуже реализован — но нигде не вызывается, лимит времени сейчас не принуждается.
Все пункты ниже — то, чего в коде пока нет.
Архитектурный рефакторинг: организация кода по вертикальным срезам
Отдельный от фаз 1–7 вопрос — не про функциональность, а про то, как раскладывать код по файлам по мере роста 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. Два места будут этому сопротивляться:
- Контракт
IQuizApi/ITeacherApi/IAdminApiвDomain.Contracts(DESIGN.md §4) — это по сути RPC-интерфейс "один record на роль", а не роутинг по фиче. Fable.Remoting-style клиент (Api.fs) уже завязан на паттерн/api/<TypeName>/<Method>по этим record'ам. - 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, фазы 3–4).
Когда применять
- Не блокирует фазы 1–2 (домен/БД) — там организация по типу файла (
Users.fs/Quizzes.fs/...,Store.fs) уже естественна и менять её не нужно. - Применяется как соглашение начиная с фазы 3 (Admin API) — новый код сразу пишется в
Features/-структуре; уже написанныйQuizApi.fsне рефакторится превентивно, только когда до него дойдёт очередная правка (рефакторинг не ради рефакторинга).
Фаза 1 — Домен (DESIGN.md §3)
1.1 Пользователь (§3.1)
User.IsActiveUser.CreatedAtloginпроверяетIsActive, отказывает тем же текстом ошибки, что и неверный пароль
1.2 Темы и вопросы (§3.2)
QuestionCategory→Topic,CategoryId→TopicIdTopic.OwnerId(вместоCourseId),Topic.CreatedAtQuestion.TopicId(вместоCategoryId)Question.IsArchived,Question.CreatedAt- Правило мягкого удаления вопроса (архивировать вместо удаления, если используется) — сама флаг-логика в домене; серверная проверка "используется ли где-то" — фаза 4
1.3 Состав теста (§3.3)
RandomTopicRuleQuizComposition(FixedQuestions/RandomFromTopics)Quiz.OwnerId(вместоCourseId)Quiz.AssignedStudentIdsQuiz.IsPublished,Quiz.IsArchived,Quiz.CreatedAtQuiz.totalPointsпересчитан подComposition(сумма поFixedQuestionsилиCount * PointsPerQuestionпоRandomFromTopics)- Удалены
Course,Enrollment,CourseId,EnrollmentRole
1.4 Резолв и попытка (§3.4, §3.6)
AttemptFinishReason(ManualSubmit/TimedOut)Attempt.AttemptNumberAttempt.FinishReasonAttempt.ResolvedQuestionsAttempt.FocusLossCountQuiz.resolveComposition(чистая функция, shuffle и вопросы темы — параметрами)Attempt.submitпринимаетAttemptFinishReasonGrading.gradeAttemptпереключён наattempt.ResolvedQuestionsвместоquiz.Questions- Проверка
Attempt.isExpiredвстроена в потокsubmitAnswer/finishAttempt(авто-завершение сFinishReason = TimedOut)
1.5 Типы аналитики и валидация (§3.5, §3.7)
QuestionStatAttemptSummary,AttemptDetail,StudentQuizResultValidation.fs:QuizValidationпереработан подQuizComposition(сейчас требует непустойquiz.Questions, нужно — непустойFixedQuestionsили хотя бы одно правило сCount > 0вRandomFromTopics)
1.6 Юнит-тесты (tests/Domain.Tests)
resolveComposition:FixedQuestions(с шаффлом и без),RandomFromTopics(успешный набор, ошибка при нехватке вопросов в теме)totalPointsдля обоих режимовCompositionapplyGradingMethod: добавить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.listUsersIAdminApi.createUser(с выборомRole)IAdminApi.updateUserIAdminApi.deactivateUserIAdminApi.resetPassword- Проверка роли
Adminна сервере для всех методовIAdminApi - UI: список пользователей, создание/деактивация/сброс пароля
Фаза 4 — Teacher API + UI (§4.2)
ITeacherApi:listTopics/createTopic/renameTopic/deleteTopicITeacherApi:listQuestions/createQuestion/updateQuestion/deleteQuestion(с архивированием вместо удаления, если вопрос используется — §3.2)ITeacherApi:listMyQuizzes/getQuiz/createQuiz/updateQuizITeacherApi:publishQuiz/unpublishQuizITeacherApi:deleteQuiz(архивирование, если есть попытки — §3.3)ITeacherApi:listStudents/assignStudents/unassignStudent- Проверка владения (
OwnerId) на каждом хендлере, кромеlistStudents - UI: темы → вопросы → конструктор теста (fixed-список / random-по-темам) → назначение студентов
Фаза 5 — Результаты и аналитика (§3.5, §3.7, §4.2)
- Запись в
attempt_gradesприfinishAttempt/авто-завершении ITeacherApi.getQuizAttemptsITeacherApi.getAttemptDetailITeacherApi.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)