89 Commits

Author SHA1 Message Date
Renovate Bot 8223697bd3 chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 44s
2026-05-25 00:32:40 +00:00
serega404 3106f0ef61 Merge pull request 'Dev' (#11) from dev into main
Backend CI / build-and-test (push) Successful in 39s
Frontend CI / build-and-check (push) Failing after 5m18s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 16s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
Reviewed-on: #11
2026-05-25 03:22:55 +03:00
serega404 c4ed23a3d9 refactor: настроил линтер против сиротского css
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m22s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 26s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 4s
Backend CI / build-and-test (pull_request) Successful in 54s
Frontend CI / build-and-check (pull_request) Failing after 5m4s
2026-05-25 02:15:34 +03:00
serega404 98aaa86ec4 refactor: натравил форматтер на весь фронт 2026-05-25 02:06:11 +03:00
serega404 24df65a13c fix: lint
Frontend CI / build-and-check (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been cancelled
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been cancelled
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Has been cancelled
2026-05-25 02:03:45 +03:00
serega404 de52b4ddb8 feat: обновил страницу входа
Frontend CI / build-and-check (push) Failing after 15m20s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been cancelled
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been cancelled
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been cancelled
2026-05-25 01:52:17 +03:00
serega404 85ef2a1c22 feat: улучшил синхронизацию лекций
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 10m14s
Frontend CI / build-and-check (push) Failing after 16m12s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14m7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m59s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m57s
Backend CI / build-and-test (push) Failing after 13m27s
2026-05-24 23:47:23 +03:00
serega404 a8a20f9b0b refactor: почистил фронтенд 2026-05-24 22:33:40 +03:00
serega404 90300b0644 refactor: Удалил пункт профиля из навигации
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 12m0s
Frontend CI / build-and-check (push) Failing after 17m56s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14m7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m58s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m59s
2026-05-24 21:34:59 +03:00
serega404 e56b577772 feat: улучшил создание лекций 2026-05-24 21:32:22 +03:00
serega404 99d25adbb1 feat: перелопатил синхронизацию преподавателей
Backend CI / build-and-test (push) Failing after 13m11s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 10m12s
Frontend CI / build-and-check (push) Failing after 16m9s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14m6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m58s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m58s
2026-05-24 21:06:03 +03:00
serega404 6aef5dd66f fix: admin ui
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 11m59s
Frontend CI / build-and-check (push) Failing after 17m52s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 13m15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m7s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 15m0s
2026-05-22 01:43:27 +03:00
serega404 8ac593d36f feat: изменил логику анализа отзывов
Backend CI / build-and-test (push) Failing after 14m19s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 12m5s
Frontend CI / build-and-check (push) Failing after 17m58s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 10m11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 11m3s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m58s
2026-05-22 01:30:41 +03:00
serega404 168d6af860 feat: добавил quartz.net dashboard для разработки 2026-05-21 23:20:33 +03:00
serega404 935e4ed37a feat: добавил изменение промта для админа
Backend CI / build-and-test (push) Failing after 11m26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 14m2s
Frontend CI / build-and-check (push) Failing after 19m55s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14m7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m59s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 15m0s
2026-05-21 21:58:33 +03:00
serega404 27a2811806 feat: добавил описание кнопок в топбаре 2026-05-21 19:53:32 +03:00
serega404 32f28898f5 feat: унифицировал и обновил модалки 2026-05-21 19:49:03 +03:00
serega404 2e7ce6c2e8 feat: добавил ограничение записи на лекции
Backend CI / build-and-test (push) Failing after 32s
Frontend CI / build-and-check (push) Failing after 5m5s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 6s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 1m28s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
2026-05-21 19:34:08 +03:00
serega404 32b8bdfd24 fix: design 2026-05-21 15:38:44 +03:00
serega404 b52318b992 feat: Добавил попап зачем монеты
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 19s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
2026-05-18 05:17:04 +03:00
serega404 55369301f0 refactor: убрал поиск сверху
Frontend CI / build-and-check (push) Failing after 5m13s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 19s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
2026-05-18 03:43:16 +03:00
serega404 2e4ccad894 refactor: перенёс достижения в профиль 2026-05-18 03:41:49 +03:00
serega404 19ea303782 feat: переделал все клиентские запросы на другие endpoint для безопастности
Backend CI / build-and-test (push) Successful in 48s
Frontend CI / build-and-check (push) Failing after 5m13s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m9s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 26s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 14s
2026-05-18 03:27:16 +03:00
serega404 6eeacd80cc fix: скрыл инфу о активности ака и перестал выдавать рефреши если ак не активен 2026-05-18 03:15:45 +03:00
serega404 934682f035 feat: Добавил постраничную загрузку отзывов
Frontend CI / build-and-check (push) Failing after 5m10s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
2026-05-18 02:54:28 +03:00
serega404 b984d29c50 fix: небольшие фиксы фронта 2026-05-18 02:46:49 +03:00
serega404 811b6ef51a fix: перенёс уровни в бд и пофиксид их отображение на фронте
Backend CI / build-and-test (push) Successful in 52s
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m0s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s
2026-05-18 02:28:05 +03:00
serega404 302e01d705 feat: добавил вкладку с отзывами для админа
Backend CI / build-and-test (push) Failing after 28s
Frontend CI / build-and-check (push) Failing after 5m9s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 31s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 23s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
2026-05-16 11:41:56 +03:00
serega404 2f32df0b1a fix: починил фильтры
Frontend CI / build-and-check (push) Failing after 5m12s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 23s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
2026-05-16 11:38:14 +03:00
serega404 a0ca50a718 fix: роль сбрасывалась при перезагрузке
Frontend CI / build-and-check (push) Failing after 5m12s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 22s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
2026-05-16 11:22:51 +03:00
serega404 926688cd2e feat: добавил напоминания о лекциях
Backend CI / build-and-test (push) Successful in 51s
Frontend CI / build-and-check (push) Failing after 5m11s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 14s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m20s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 23s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
2026-05-16 11:19:31 +03:00
serega404 373e551bea feat: Добавил управление ролями пользователей в админке
Frontend CI / build-and-check (push) Failing after 5m12s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 14s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 22s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
2026-05-16 11:06:15 +03:00
serega404 e8a4622fa8 Добавил в админку создание фиктивных лекций
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 31s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
2026-05-16 10:56:21 +03:00
serega404 3ba6fe940e fix: Скорректировал ширину вкладок и текст пустого состояния
Frontend CI / build-and-check (push) Failing after 5m8s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m12s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 28s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-14 06:05:16 +03:00
serega404 6dff7e6ca1 feat: Добавил синхронизацию преподавателей из лекций
Backend CI / build-and-test (push) Successful in 55s
Frontend CI / build-and-check (push) Failing after 5m9s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 12s
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been skipped
🚀 Create and publish a Docker image / Build & publish frontend image (push) Has been skipped
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
2026-05-14 05:47:31 +03:00
serega404 dab161ef18 fix(ui): Улучшил точность активного состояния навигации и удалил кнопочку добавления пользователей 2026-05-14 05:45:14 +03:00
serega404 69c726fdc9 fix: фикс размера окошка с выбором
Frontend CI / build-and-check (push) Failing after 6m21s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 14s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 20s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-14 03:12:02 +03:00
serega404 d37b5933f3 fix: окошка аватара
Frontend CI / build-and-check (push) Failing after 5m10s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 12s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 27s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-14 02:57:40 +03:00
serega404 e9d232fc22 ci: добавил ci для фронта 2026-05-14 02:57:08 +03:00
serega404 fbec0cc08a refactor: перевёл цвета на CSS-переменные 2026-05-14 02:44:44 +03:00
serega404 a42a305a12 design: улучшения дизайна
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 20s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-14 02:16:18 +03:00
serega404 fef6962fa7 refactor: упростил настройки уведомлений 2026-05-14 02:08:24 +03:00
serega404 8d4b9ffeec refactor: обновил иконку монеты
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 14s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-14 02:07:02 +03:00
serega404 5a1ddb82e6 fix: ui fixes
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m24s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 29s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-14 02:02:02 +03:00
serega404 d29b52f824 fix: лекции когда возвращались не говорили записан ли студент уже или нет 2026-05-13 20:01:43 +03:00
serega404 65e3d1bf18 fix: отображение количества участников лекции
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 6m5s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 29s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-13 19:49:51 +03:00
serega404 f6aaf0b923 fix: скрыл отзывы от студентов
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m17s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 29s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-05-13 19:16:48 +03:00
serega404 7761238719 feat: Добавил генерацию api документации для git
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 36s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
2026-05-13 17:12:42 +03:00
serega404 98ad8ae74f docs: добавил подробную документацию по backend
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 14s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 17s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
2026-05-13 00:48:50 +03:00
serega404 b0a4a6d259 feat: добавил личные уведомления для пользователей
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 26s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
Реализовал хранение, получение и отметку прочитанными пользовательских уведомлений. Обновил фронтенд для отображения и управления уведомлениями в профиле студента.
2026-05-12 23:54:55 +03:00
serega404 feff77b232 feat: добавил каталог достижений и автоначисление
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m53s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 28s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
Реализовал автосоздание и обновление каталога достижений на бэке и синхронизацию на фронте.
2026-05-12 23:21:39 +03:00
serega404 dbba2be277 refactor: убрал отображение роли пользователя в топбаре
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 12s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
2026-05-12 14:30:43 +03:00
serega404 fcd30f9bf7 feat: добавил отображение пустого состояния уведомлений
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 13s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 19s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
Реализовал компонент для отображения пустого состояния при отсутствии уведомлений.
2026-05-12 14:14:32 +03:00
serega404 17093784e2 refactor: убрал иконку приветствия из заголовка
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 14s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 27s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
2026-05-12 14:04:45 +03:00
serega404 462cbb360d fix: скрыл информацию о студенте если её нет 2026-05-12 00:49:15 +03:00
serega404 860964e3c2 fix: синхронизации лекций
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m3s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 25s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
2026-05-12 00:44:34 +03:00
serega404 9b28a09253 feat: подробное отображение ошибок синхронизации 2026-05-12 00:18:47 +03:00
serega404 fb8ad6de7c feat: добавил типы мероприятий 2026-05-12 00:13:19 +03:00
serega404 34334e9a8d fix: синхронизации аудиторий 2026-05-11 23:59:13 +03:00
serega404 6824d7ce7d feat: мультироль
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m6s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 26s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-11 21:29:16 +03:00
serega404 3b0bbfc858 feat: добавил svg иконки
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 12s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 25s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-11 14:43:14 +03:00
serega404 a0a0575a99 feat: добавил отправку уведомлений по почте
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m17s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 12s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-11 05:09:00 +03:00
serega404 44234cc42d feat: добавил конфигурацию CI для сборки и тестирования бэкенда
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m3s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 17s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-11 03:44:56 +03:00
serega404 f168050637 feat: добавил интеграционные тесты 2026-05-11 03:42:47 +03:00
serega404 fc380c7c51 feat: добавил функцию форматирования имени пользователя и применил её в верхней панели и на главной странице
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 24s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-11 02:04:42 +03:00
serega404 610c15c9fd feat: добавил кабинеты преподавателя и администратора
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 38s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 18s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-11 01:58:09 +03:00
serega404 779b6aba77 feat: первое подключение фронтенда
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 54s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 27s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s
2026-05-11 01:33:38 +03:00
serega404 71e7d84e0f feat: добавил HTTP-эндпоинт для фронтенда в конфигурацию Aspire 2026-05-11 01:32:51 +03:00
serega404 75282fe8dd fix: обновил версии пакетов в проектах бэка 2026-05-11 01:16:30 +03:00
serega404 b42e4e5157 feat: добавил файл конфигурации Directory.Build.props и обновил настройки launchSettings.json 2026-05-11 01:16:09 +03:00
serega404 8e376de9f0 feat: добавил файлы конфигурации проекта jetbrains 2026-05-11 00:21:24 +03:00
serega404 331ad86c51 fix: обновил версии пакетов Microsoft.OpenApi и FluentValidation.DependencyInjectionExtensions 2026-05-11 00:21:07 +03:00
serega404 a04c20c857 feat: добавил .net Aspire 2026-05-11 00:20:39 +03:00
serega404 aaba62b739 docs: добавил исключение для appsettings.Development.json
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 25s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 11s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 5s
2026-05-10 22:38:31 +03:00
serega404 2450361a1d docs: добавил параметры Azure AD в dev конфигурацию 2026-05-10 22:38:00 +03:00
serega404 6e473e23d0 docs: перенёс swagger на api/docs
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m55s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 26s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 5s
2026-05-10 22:13:46 +03:00
serega404 32ca5963c8 feat: доделал MS Auth для фронта 2026-05-10 21:57:19 +03:00
serega404 3af1932480 docs: обновил документацию
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 44s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 22s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-08 03:02:09 +03:00
serega404 444415c84b ci: fix dockerfile
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 18s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-08 01:29:54 +03:00
serega404 6926565d70 ci: Включил ci/cd для фронта
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-08 01:25:28 +03:00
serega404 99a9c3bf4d feat: добавил докер файл для фронта 2026-05-08 01:24:17 +03:00
serega404 047611fd24 feat: подготовил дизайн (изменения из другого репозитория)
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-08 01:06:22 +03:00
serega404 655ab1b5c5 Инициализировал Vue
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 9s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-08 00:56:44 +03:00
serega404 e4d47d4dff fix: настроек приложения
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 3s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 21s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 2s
2026-05-06 16:57:41 +03:00
serega404 7c18dbc014 chore: обновил миграции 2026-05-06 16:53:15 +03:00
serega404 89c81c8e27 docs: добавил env example
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 4s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 1m3s
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 16:17:58 +03:00
serega404 8f53fcfe13 fix: docker context
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 4s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 1s
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 15:55:50 +03:00
serega404 e64f287ca3 docs: добавил примеры compose
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 4s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 16s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
2026-05-06 15:37:14 +03:00
serega404 31883c579b ci: добавил сборку с проверкой изменений
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Has been skipped
🚀 Create and publish a Docker image / Update stack on Portainer (push) Has been skipped
2026-05-06 15:25:43 +03:00
251 changed files with 32936 additions and 343 deletions
+40
View File
@@ -0,0 +1,40 @@
# Postgre
POSTGRES_USER=universe
POSTGRES_PASSWORD=
POSTGRES_DATABASE=universe
# Azure AD
AzureAd_Instance=https://login.microsoftonline.com/
AzureAd_TenantId=sfedu.ru
AzureAd_ClientId=
AzureAd_ClientSecret=
AzureAd_Domain=sfedu.onmicrosoft.com
AzureAd_CallbackPath=/signin-oidc
# JWT
JWT_SECRET=
JWT_ISSUER=UniVerse
JWT_AUDIENCE=UniVerse
JWT_ACCESS_TOKEN_EXPIRATION_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRATION_DAYS=30
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:3000
# LLM
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=
# Modeus API
MODEUS_API_BASE_URL=
MODEUS_API_KEY=
# Email SMTP
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_SMTP_ENABLE_SSL=true
EMAIL_SMTP_USERNAME=
EMAIL_SMTP_PASSWORD=
EMAIL_SMTP_FROM_ADDRESS=no-reply@universe.local
EMAIL_SMTP_FROM_NAME=UniVerse
+33
View File
@@ -0,0 +1,33 @@
name: Backend CI
on:
push:
branches: [ "main", "dev" ]
paths:
- 'backend/**'
pull_request:
branches: [ "main", "dev" ]
paths:
- 'backend/**'
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore backend/UniVerse.sln
- name: Build
run: dotnet build backend/UniVerse.sln --no-restore --configuration Release
- name: Test
run: dotnet test backend/UniVerse.sln --no-build --configuration Release --verbosity normal
+63
View File
@@ -0,0 +1,63 @@
name: Frontend CI
on:
push:
branches: [ "main", "dev" ]
paths:
- 'frontend/**'
pull_request:
branches: [ "main", "dev" ]
paths:
- 'frontend/**'
jobs:
build-and-check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Audit dependencies
if: always()
run: pnpm audit --audit-level moderate
- name: Check formatting
if: always()
run: pnpm exec prettier --check src/
- name: Lint with oxlint
if: always()
run: pnpm exec oxlint .
- name: Lint with ESLint
if: always()
run: pnpm exec eslint . --max-warnings=0
- name: Type check
if: always()
run: pnpm run type-check
- name: Build
if: always()
run: pnpm run build-only
+96 -13
View File
@@ -1,17 +1,48 @@
name: Create and publish a Docker image
name: 🚀 Create and publish a Docker image
on:
push:
branches: ['main', 'staging']
branches: ['main', 'dev']
env:
CONTEXT: ./backend
BACKEND_PATH: backend
FRONTEND_PATH: frontend
SERVER_DOMAIN: ${{ gitea.server_url.replace('https://', '') }}
jobs:
build-and-push-image:
detect-changes:
runs-on: ubuntu-latest
name: Publish image
name: Detect changes in backend and frontend
container: catthehacker/ubuntu:act-latest
outputs:
backend_changed: ${{ steps.backend-changed.outputs.backend }}
frontend_changed: ${{ steps.frontend-changed.outputs.frontend }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Check for backend changes
id: backend-changed
uses: dorny/paths-filter@v2
with:
filters: |
backend:
- '${{ env.BACKEND_PATH }}/**'
- name: Check for frontend changes
id: frontend-changed
uses: dorny/paths-filter@v2
with:
filters: |
frontend:
- '${{ env.FRONTEND_PATH }}/**'
backend:
runs-on: ubuntu-latest
name: Build & publish backend image
container: catthehacker/ubuntu:act-latest
needs: [detect-changes]
if: ${{ needs.detect-changes.outputs.backend_changed == 'true' }}
permissions:
contents: read
packages: write
@@ -24,17 +55,69 @@ jobs:
id: meta
uses: https://github.com/docker/metadata-action@v4
with:
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}
- name: Build an image from Dockerfile
run: |
cd ${{ env.CONTEXT }} &&
docker build -f UniVerse.Api/Dockerfile -t ${{ steps.meta.outputs.tags }} .
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/backend
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ vars.SERVER_DOMAIN }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Push
run: |
docker push '${{ steps.meta.outputs.tags }}'
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: ./${{ env.BACKEND_PATH }}
file: ./${{ env.BACKEND_PATH }}/UniVerse.Api/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
frontend:
runs-on: ubuntu-latest
name: Build & publish frontend image
container: catthehacker/ubuntu:act-latest
needs: [detect-changes]
if: ${{ needs.detect-changes.outputs.frontend_changed == 'true' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ vars.SERVER_DOMAIN }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: https://github.com/docker/metadata-action@v4
with:
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/frontend
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: ./${{ env.FRONTEND_PATH }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy:
runs-on: ubuntu-latest
needs: [frontend, backend]
# always() - костыль для того, чтобы деплой выполнялся даже если один из билдов пропущен
if: github.ref == 'refs/heads/dev' && always() && (needs.backend.result == 'success' || needs.frontend.result == 'success')
name: Update stack on Portainer
steps:
- name: Deploy Stage
uses: fjogeleit/http-request-action@v1
with:
url: ${{ secrets.PORTAINER_WEBHOOK_URL }}
method: 'POST'
ignoreSsl: true
timeout: 60000
+2
View File
@@ -141,6 +141,7 @@ $RECYCLE.BIN/
# Icon must end with two \r
Icon
# Thumbnails
._*
@@ -160,3 +161,4 @@ Network Trash Folder
Temporary Items
.apdisk
backend/UniVerse.Api/appsettings.Development.json
+24 -1
View File
@@ -2,6 +2,9 @@
UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
[Документация API](backend/UniVerse.Api/openapi.json)
[Документация бекнда](docs/backend.md)
## Что внутри
- Расписание/события и сущности: курсы, лекции, аудитории (locations)
@@ -101,9 +104,13 @@ docker run --rm -p 8080:8080 \
## Аутентификация
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
- `POST /api/v1/auth/login/microsoft`заготовка под Microsoft Entra ID (сейчас не реализовано).
- `GET /api/v1/auth/login/microsoft`старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft).
- `GET /api/v1/auth/callback/microsoft` — callback, куда Microsoft возвращает `code`.
- `POST /api/v1/auth/login/microsoft` — обмен `authorizationCode` на токены (полезно для интеграций/ручных тестов). Тело: `{ "authorizationCode": "...", "redirectUri"?: "..." }`.
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`).
Большинство методов API защищены `[Authorize]`.
## Фоновый LLM-анализ отзывов
@@ -140,3 +147,19 @@ LLM-ключ задаётся через `Llm:ApiKey`.
Точные схемы запросов/ответов удобнее смотреть в Swagger.
## Тестирование
В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
- **xUnit** в качестве основного фреймворка для тестирования.
- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
Запуск тестов:
```bash
cd backend
dotnet test
```
+1
View File
@@ -0,0 +1 @@
UniVerse
+3 -1
View File
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<attachedFolders>
<Path>../frontend</Path>
</attachedFolders>
<explicitIncludes />
<explicitExcludes />
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<BuildInParallel>false</BuildInParallel>
<RestoreUseStaticGraphEvaluation>true</RestoreUseStaticGraphEvaluation>
</PropertyGroup>
</Project>
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Auth;
public class AuthServiceTests
{
[Fact]
public async Task RefreshTokenAsync_InactiveUser_RevokesTokenAndThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
db.RefreshTokens.Add(new RefreshToken
{
Id = 1,
UserId = 1,
Token = "refresh-token",
ExpiresAt = DateTime.UtcNow.AddDays(1),
CreatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.RefreshTokenAsync("refresh-token"));
var token = await db.RefreshTokens.SingleAsync(t => t.Token == "refresh-token");
Assert.NotNull(token.RevokedAt);
}
[Fact]
public async Task GetCurrentUserAsync_InactiveUser_ThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
}
[Fact]
public async Task LoginWithMicrosoftAsync_LinksScheduleTeacherBySubId()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 10,
Email = "modeus-person-1@modeus.local",
DisplayName = "Иванов Иван Иванович",
MicrosoftId = "sso-sub-1",
IsActive = true,
Roles = [new UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new TeacherProfile { UserId = 10, ModeusId = "person-1" }
});
await db.SaveChangesAsync();
var microsoftAuth = Substitute.For<IMicrosoftAuthClient>();
microsoftAuth.ExchangeAuthorizationCodeAsync("code", "http://localhost/callback", Arg.Any<CancellationToken>())
.Returns(new MicrosoftTokenResult(BuildIdToken("sso-sub-1", "teacher@sfedu.ru", "Иванов Иван Иванович")));
var service = CreateService(db, microsoftAuth);
var result = await service.LoginWithMicrosoftAsync("code", "http://localhost/callback");
Assert.Equal(10, result.Response.User.Id);
Assert.Equal("teacher@sfedu.ru", result.Response.User.Email);
Assert.Contains(UserRole.Teacher, result.Response.User.Roles);
Assert.Single(await db.Users.ToListAsync());
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
Assert.Equal("sso-sub-1", user.MicrosoftId);
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"AuthServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static AuthService CreateService(AppDbContext db, IMicrosoftAuthClient? microsoftAuth = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-secret-test-secret-test-secret-test-secret",
["Jwt:Issuer"] = "UniVerse.Tests",
["Jwt:Audience"] = "UniVerse.Tests",
["Jwt:AccessTokenExpirationMinutes"] = "15",
["Jwt:RefreshTokenExpirationDays"] = "30"
})
.Build();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var notifications = Substitute.For<INotificationService>();
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
microsoftAuth ??= Substitute.For<IMicrosoftAuthClient>();
return new AuthService(db, config, microsoftAuth, gamification, notifications, NullLogger<AuthService>.Instance);
}
private static string BuildIdToken(string sub, string email, string name)
{
var token = new JwtSecurityToken(claims:
[
new Claim(JwtRegisteredClaimNames.Sub, sub),
new Claim("preferred_username", email),
new Claim("name", name)
]);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,317 @@
using System.Net;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.Authorization;
/// <summary>
/// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API.
///
/// Каждый тестовый случай представляет собой кортеж:
/// (description, method, url, requiredRole, forbiddenRoles[])
///
/// Три типа сценариев для каждой конечной точки:
/// A) Анонимный → 401 Unauthorized
/// B) Неправильная роль → 403 Forbidden
/// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка)
/// </summary>
public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
public EndpointAuthorizationTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
// ─────────────────────────────────────────────────────────────────────────
// Тестовые данные
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Конечные точки, требующие аутентификации (не анонимные).
/// Формат: (description, method, url, correctRole, forbiddenRoles[])
///
/// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли.
/// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные.
/// </summary>
public static IEnumerable<object[]> AuthenticatedEndpoints()
{
// ── Auth ─────────────────────────────────────────────────────────────
yield return E("auth/logout [AnyAuth]", "POST", "api/v1/auth/logout", "Student");
yield return E("auth/me [AnyAuth]", "GET", "api/v1/auth/me", "Student");
// ── Users — current user ──────────────────────────────────────────────
yield return E("users/me GET [AnyAuth]", "GET", "api/v1/users/me", "Student");
yield return E("users/me PUT [AnyAuth]", "PUT", "api/v1/users/me", "Student",
body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
// ── Users — Admin only ────────────────────────────────────────────────
yield return E("users GET [Admin]", "GET", "api/v1/users", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} GET [Admin]", "GET", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} PUT [Admin]", "PUT", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/{id}/stats [Admin]", "GET", "api/v1/users/1/stats", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/enrollments [Admin]", "GET", "api/v1/users/1/enrollments", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/reviews [Admin]", "GET", "api/v1/users/1/reviews","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/achievements [Admin]", "GET", "api/v1/users/1/achievements","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/transactions [Admin]", "GET", "api/v1/users/1/transactions","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/role PATCH [Admin]", "PATCH", "api/v1/users/1/role", "Admin", forbidden: ["Student", "Teacher"],
body: "\"Student\"");
yield return E("users/{id}/active PATCH [Admin]", "PATCH", "api/v1/users/1/active", "Admin", forbidden: ["Student", "Teacher"],
body: "true");
// ── Courses — any auth ────────────────────────────────────────────────
yield return E("courses GET [AnyAuth]", "GET", "api/v1/courses", "Student");
yield return E("courses/{id} GET [AnyAuth]", "GET", "api/v1/courses/1", "Student");
// ── Courses — Admin only ──────────────────────────────────────────────
yield return E("courses POST [Admin]", "POST", "api/v1/courses", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Course","description":null}""");
yield return E("courses/{id} PUT [Admin]", "PUT", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Course","description":null}""");
yield return E("courses/{id} DELETE [Admin]", "DELETE", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("courses/{id}/tags POST [Admin]", "POST", "api/v1/courses/1/tags", "Admin", forbidden: ["Student", "Teacher"],
body: "1");
yield return E("courses/{id}/tags/{tagId} DELETE [Admin]","DELETE","api/v1/courses/1/tags/1","Admin",forbidden: ["Student", "Teacher"]);
// ── Lectures — any auth ───────────────────────────────────────────────
yield return E("lectures GET [AnyAuth]", "GET", "api/v1/lectures", "Student");
yield return E("lectures/{id} GET [AnyAuth]", "GET", "api/v1/lectures/1", "Student");
// ── Lectures — Admin only ─────────────────────────────────────────────
yield return E("lectures POST [Admin]", "POST", "api/v1/lectures", "Admin", forbidden: ["Student", "Teacher"],
body: """{"courseId":1,"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id} DELETE [Admin]", "DELETE", "api/v1/lectures/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Lectures — Admin OR Teacher ───────────────────────────────────────
yield return E("lectures/{id} PUT [Admin]", "PUT", "api/v1/lectures/1", "Admin", forbidden: ["Student"],
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id} PUT [Teacher]", "PUT", "api/v1/lectures/1", "Teacher", forbidden: ["Student"],
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id}/attendance PATCH [Admin]", "PATCH","api/v1/lectures/1/attendance/2","Admin", forbidden: ["Student"],
body: "true");
yield return E("lectures/{id}/attendance PATCH [Teacher]","PATCH","api/v1/lectures/1/attendance/2","Teacher",forbidden: ["Student"],
body: "true");
yield return E("lectures/{id}/enrollments GET [Admin]", "GET","api/v1/lectures/1/enrollments","Admin", forbidden: ["Student"]);
yield return E("lectures/{id}/enrollments GET [Teacher]","GET","api/v1/lectures/1/enrollments","Teacher",forbidden: ["Student"]);
yield return E("lectures/{id}/reviews GET [Admin]", "GET","api/v1/lectures/1/reviews","Admin", forbidden: ["Student"]);
yield return E("lectures/{id}/reviews GET [Teacher]", "GET","api/v1/lectures/1/reviews","Teacher",forbidden: ["Student"]);
// ── Lectures — Student only ───────────────────────────────────────────
yield return E("lectures/{id}/enroll POST [Student]", "POST", "api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
yield return E("lectures/{id}/enroll DELETE [Student]", "DELETE","api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
// ── Reviews — any auth ────────────────────────────────────────────────
yield return E("reviews/{id} PUT [AnyAuth]", "PUT", "api/v1/reviews/1", "Student",
body: """{"rating":"Like","text":"Updated"}""");
yield return E("reviews/{id} DELETE [AnyAuth]", "DELETE", "api/v1/reviews/1", "Student");
// ── Reviews — Admin OR Teacher ───────────────────────────────────────
yield return E("reviews/{id} GET [Admin]", "GET", "api/v1/reviews/1", "Admin", forbidden: ["Student"]);
yield return E("reviews/{id} GET [Teacher]", "GET", "api/v1/reviews/1", "Teacher", forbidden: ["Student"]);
// ── Reviews — Student only ────────────────────────────────────────────
yield return E("reviews POST [Student]", "POST", "api/v1/reviews", "Student", forbidden: ["Admin", "Teacher"],
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
// ── Reviews — Admin only ──────────────────────────────────────────────
yield return E("reviews GET [Admin]", "GET", "api/v1/reviews", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt GET [Admin]", "GET", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
// ── Tags — any auth ───────────────────────────────────────────────────
yield return E("tags GET [AnyAuth]", "GET", "api/v1/tags", "Student");
yield return E("tags/{id} GET [AnyAuth]", "GET", "api/v1/tags/1", "Student");
yield return E("tags/tree GET [AnyAuth]", "GET", "api/v1/tags/tree", "Student");
// ── Tags — Admin only ─────────────────────────────────────────────────
yield return E("tags POST [Admin]", "POST", "api/v1/tags", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Tag","type":"Topic","parentId":null}""");
yield return E("tags/{id} PUT [Admin]", "PUT", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Tag","type":"Topic","parentId":null}""");
yield return E("tags/{id} DELETE [Admin]", "DELETE", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Locations — any auth ──────────────────────────────────────────────
yield return E("locations GET [AnyAuth]", "GET", "api/v1/locations", "Student");
yield return E("locations/{id} GET [AnyAuth]", "GET", "api/v1/locations/1", "Student");
// ── Locations — Admin only ────────────────────────────────────────────
yield return E("locations POST [Admin]", "POST", "api/v1/locations", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
yield return E("locations/{id} PUT [Admin]", "PUT", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
yield return E("locations/{id} DELETE [Admin]", "DELETE", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Achievements — any auth ───────────────────────────────────────────
yield return E("achievements GET [AnyAuth]", "GET", "api/v1/achievements", "Student");
yield return E("achievements/{id} GET [AnyAuth]", "GET", "api/v1/achievements/1", "Student");
// ── Achievements — Admin only ─────────────────────────────────────────
yield return E("achievements POST [Admin]", "POST", "api/v1/achievements", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
yield return E("achievements/{id} PUT [Admin]", "PUT", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
yield return E("achievements/{id} DELETE [Admin]", "DELETE", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Sync — Admin only ─────────────────────────────────────────────────
yield return E("sync/schedule POST [Admin]", "POST", "api/v1/sync/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: """{"specialtyCode":null,"timeMin":null,"timeMax":null,"typeId":null}""");
yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
// ── Notifications — any auth ───────────────────────────────────────────
yield return E("notifications GET [AnyAuth]", "GET", "api/v1/notifications", "Student");
yield return E("notifications/read-all PATCH [AnyAuth]", "PATCH", "api/v1/notifications/read-all", "Student");
// ── Notifications — Admin only ─────────────────────────────────────────
yield return E("notifications/send POST [Admin]", "POST", "api/v1/notifications/send", "Admin", forbidden: ["Student", "Teacher"],
body: """{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello"}""");
yield return E("notifications/schedule POST [Admin]", "POST", "api/v1/notifications/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: $$"""{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello","sendAt":"{{DateTimeOffset.UtcNow.AddMinutes(5):O}}"}""");
}
/// <summary>
/// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401.
/// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401)
/// </summary>
public static IEnumerable<object[]> AnonymousEndpoints()
{
// login/microsoft GET перенаправляет на Microsoft — AzureAd настроен в фабрике
yield return new object[] { "auth/login/microsoft GET", "GET", "api/v1/auth/login/microsoft" };
// callback разрешает анонимный доступ — возвращает 400, если отсутствует параметр code
yield return new object[] { "auth/callback/microsoft GET", "GET", "api/v1/auth/callback/microsoft" };
// dev login доступен в окружении Development
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: анонимный → 401
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_Anonymous_Returns401(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
// Подготовка — без заголовка аутентификации
var request = BuildRequest(method, url, body, authHeader: null);
// Действие
var response = await _client.SendAsync(request);
// Проверка
Assert.True(
response.StatusCode == HttpStatusCode.Unauthorized,
$"[{description}] Ожидался ответ 401 Unauthorized для анонимного запроса, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: неправильная роль → 403
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_WrongRole_Returns403(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
foreach (var forbidden in forbiddenRoles)
{
// Подготовка
var request = BuildRequest(method, url, body,
authHeader: TestJwtFactory.BearerHeader(forbidden));
// Действие
var response = await _client.SendAsync(request);
// Проверка
Assert.True(
response.StatusCode == HttpStatusCode.Forbidden,
$"[{description}] Ожидался ответ 403 Forbidden для роли '{forbidden}', получено {(int)response.StatusCode} {response.StatusCode}");
}
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: правильная роль → не 401 и не 403
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_CorrectRole_PassesAuthz(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
// Подготовка
var request = BuildRequest(method, url, body,
authHeader: TestJwtFactory.BearerHeader(correctRole));
// Действие
var response = await _client.SendAsync(request);
// Проверка — принимается любой ответ, который НЕ 401/403
Assert.True(
response.StatusCode != HttpStatusCode.Unauthorized &&
response.StatusCode != HttpStatusCode.Forbidden,
$"[{description}] Роль '{correctRole}' должна успешно пройти авторизацию, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: анонимные конечные точки не должны возвращать 401
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AnonymousEndpoints))]
public async Task AnonymousEndpoint_NoToken_DoesNotReturn401(
string description, string method, string url, string? body = null)
{
var request = BuildRequest(method, url, body, authHeader: null);
var response = await _client.SendAsync(request);
Assert.True(
response.StatusCode != HttpStatusCode.Unauthorized,
$"[{description}] Анонимная конечная точка не должна возвращать 401, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Вспомогательные методы
// ─────────────────────────────────────────────────────────────────────────
private static HttpRequestMessage BuildRequest(
string method, string url, string? body, string? authHeader)
{
var request = new HttpRequestMessage(new HttpMethod(method), url);
if (authHeader != null)
request.Headers.Add("Authorization", authHeader);
if (body != null)
request.Content = new StringContent(body,
System.Text.Encoding.UTF8, "application/json");
return request;
}
/// <summary>Вспомогательный метод для компактного создания массивов объектов [MemberData].</summary>
private static object[] E(
string description,
string method,
string url,
string correctRole,
string[]? forbidden = null,
string? body = null)
=> [description, method, url, correctRole, forbidden ?? [], body];
}
@@ -0,0 +1,183 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Gamification;
public class GamificationServiceTests
{
[Fact]
public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User
{
Id = 1,
Email = "student@test.local",
DisplayName = "Student",
AvatarUrl = "avatar.png",
Xp = 100,
Coins = 510
});
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
Title = "Future lecture",
StartsAt = DateTime.UtcNow.AddDays(1),
EndsAt = DateTime.UtcNow.AddDays(1).AddHours(2),
IsOpen = true
});
db.LectureEnrollments.Add(new LectureEnrollment { UserId = 1, LectureId = 1 });
db.Reviews.AddRange(
new Review { Id = 1, UserId = 1, LectureId = 1, Rating = ReviewRating.Like },
new Review { Id = 2, UserId = 1, LectureId = 1, Rating = ReviewRating.Neutral },
new Review { Id = 3, UserId = 1, LectureId = 1, Rating = ReviewRating.Dislike });
db.CoinTransactions.Add(new CoinTransaction
{
UserId = 1,
Amount = 510,
Type = CoinTransactionType.AdminAdjustment,
Description = "Initial coins"
});
db.Achievements.AddRange(
Achievement(1001, "First activity", "first_activity:1", 10),
Achievement(1002, "Reviews", "reviews_written:3", 20),
Achievement(1003, "Active registrations", "active_registrations:1", 30),
Achievement(1004, "Coins earned", "coins_earned:500", 40),
Achievement(1005, "Level reached", "level_reached:2", 50),
Achievement(1006, "Profile completed", "profile_completed:1", 60),
Achievement(1007, "Old condition", "reviews_1", 100));
await db.SaveChangesAsync();
await service.CheckAndAwardAchievementsAsync(1);
await service.CheckAndAwardAchievementsAsync(1);
var user = await db.Users.FindAsync(1);
Assert.NotNull(user);
Assert.Equal(720, user!.Coins);
Assert.Equal(310, user.Xp);
Assert.Equal(6, await db.UserAchievements.CountAsync(ua => ua.UserId == 1));
Assert.False(await db.UserAchievements.AnyAsync(ua => ua.AchievementId == 1007));
Assert.Equal(6, await db.CoinTransactions.CountAsync(ct =>
ct.UserId == 1 && ct.Type == CoinTransactionType.AchievementReward));
}
[Fact]
public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, new DateTime(2025, 12, 29, 10, 0, 0, DateTimeKind.Utc)),
Lecture(2, new DateTime(2026, 1, 5, 10, 0, 0, DateTimeKind.Utc)),
Lecture(3, new DateTime(2026, 1, 12, 10, 0, 0, DateTimeKind.Utc)));
db.LectureEnrollments.AddRange(
new LectureEnrollment { UserId = 1, LectureId = 1, Attended = true },
new LectureEnrollment { UserId = 1, LectureId = 2, Attended = true },
new LectureEnrollment { UserId = 1, LectureId = 3, Attended = true });
db.Achievements.Add(Achievement(1001, "Streak", "attendance_streak_weeks:3", 10));
await db.SaveChangesAsync();
await service.CheckAndAwardAchievementsAsync(1);
Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001));
}
[Theory]
[InlineData(0, 1)]
[InlineData(99, 1)]
[InlineData(100, 2)]
[InlineData(299, 2)]
[InlineData(300, 3)]
public async Task CalculateLevelAsync_UsesDatabaseThresholds(int xp, int expectedLevel)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var level = await service.CalculateLevelAsync(xp);
Assert.Equal(expectedLevel, level);
}
[Theory]
[InlineData(120, 100, 300)]
[InlineData(350, 300, null)]
public async Task GetLevelProgressAsync_ReturnsCurrentAndNextThresholds(int xp, int currentLevelXp, int? nextLevelXp)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var progress = await service.GetLevelProgressAsync(xp);
Assert.Equal(currentLevelXp, progress.CurrentLevelXp);
Assert.Equal(nextLevelXp, progress.NextLevelXp);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static GamificationService CreateService(AppDbContext db)
{
var notifications = Substitute.For<INotificationService>();
notifications.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
return new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
}
private static void SeedLevelThresholds(AppDbContext db)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Achievement Achievement(int id, string name, string condition, int coinReward) => new()
{
Id = id,
Name = name,
Condition = condition,
CoinReward = coinReward
};
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2)
};
}
@@ -0,0 +1,327 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using NSubstitute;
using UniVerse.Application.DTOs.Achievements;
using UniVerse.Application.DTOs.Auth;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Courses;
using UniVerse.Application.DTOs.Gamification;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Locations;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.DTOs.Tags;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.Tests.Helpers;
/// <summary>
/// WebApplicationFactory для интеграционных тестов.
/// Заменяет Npgsql DbContext на InMemory, создает заглушки для всех интерфейсов внешних сервисов
/// и отключает фоновую службу LLM, чтобы тестам не требовалась реальная инфраструктура.
/// </summary>
public class ApiWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Используем Development, чтобы были включены Swagger и конечная точка DevLogin
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, config) =>
{
// Внедряем настройки тестового JWT — должны совпадать с константами TestJwtFactory
var testSettings = new Dictionary<string, string?>
{
["Jwt:Secret"] = TestJwtFactory.Secret,
["Jwt:Issuer"] = TestJwtFactory.Issuer,
["Jwt:Audience"] = TestJwtFactory.Audience,
// Отключаем оркестрацию Aspire
["Aspire:Enabled"] = "false",
// Фиктивные значения Azure AD (маршруты имеют атрибут [AllowAnonymous] или тестируются отдельно)
["AzureAd:TenantId"] = "test-tenant",
["AzureAd:ClientId"] = "test-client",
// Фиктивные значения LLM / Modeus (клиенты заменяются ниже)
["Llm:BaseUrl"] = "http://localhost:9999/",
["ModeusApi:BaseUrl"] = "http://localhost:9998/",
};
config.AddInMemoryCollection(testSettings);
});
builder.ConfigureServices(services =>
{
// ── 1. Заменяем Npgsql DbContext на InMemory ──────────────────────────
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.RemoveAll<AppDbContext>();
// Удаляем все регистрации, связанные с DbContext, которые добавил хост
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Находим и удаляем все дескрипторы настроек DbContext
var dbContextDescriptors = services
.Where(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)
|| d.ImplementationType == typeof(AppDbContext))
.ToList();
foreach (var d in dbContextDescriptors) services.Remove(d);
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
// ── 2. Отключаем фоновые службы ────────────────────────────────────
// Удаляем все регистрации IHostedService, чтобы предотвратить запуск фоновой задачи LLM
var hostedServices = services
.Where(d => d.ServiceType == typeof(IHostedService))
.ToList();
foreach (var d in hostedServices) services.Remove(d);
// ── 3. Создаем заглушки для всех интерфейсов Application сервисов ─────────
ReplaceWithSubstitute<IAuthService>(services, CreateAuthServiceStub());
ReplaceWithSubstitute<IUserService>(services, CreateUserServiceStub());
ReplaceWithSubstitute<ILectureService>(services, CreateLectureServiceStub());
ReplaceWithSubstitute<IReviewService>(services, CreateReviewServiceStub());
ReplaceWithSubstitute<IReviewPromptService>(services, CreateReviewPromptServiceStub());
ReplaceWithSubstitute<ICourseService>(services, CreateCourseServiceStub());
ReplaceWithSubstitute<ITagService>(services, CreateTagServiceStub());
ReplaceWithSubstitute<ILocationService>(services, CreateLocationServiceStub());
ReplaceWithSubstitute<IAchievementService>(services, CreateAchievementServiceStub());
ReplaceWithSubstitute<IGamificationService>(services, CreateGamificationServiceStub());
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
ReplaceWithSubstitute<IMicrosoftAuthClient>(services, Substitute.For<IMicrosoftAuthClient>());
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
});
}
private static void ReplaceWithSubstitute<TService>(IServiceCollection services, TService instance)
where TService : class
{
services.RemoveAll<TService>();
services.AddScoped<TService>(_ => instance);
}
// ── Фабрики заглушек ────────────────────────────────────────────────────────────
private static IAuthService CreateAuthServiceStub()
{
var stub = Substitute.For<IAuthService>();
var authResult = new AuthResult(
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
new UserAuthDto(1, "test@test.com", "Test User", [UserRole.Student])),
"refresh_token");
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
.Returns(authResult);
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<IReadOnlyCollection<UserRole>>(), Arg.Any<string?>())
.Returns(authResult);
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
stub.GetCurrentUserAsync(Arg.Any<int>())
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
return stub;
}
private static INotificationService CreateNotificationServiceStub()
{
var stub = Substitute.For<INotificationService>();
stub.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
stub.ScheduleAsync(Arg.Any<ScheduleNotificationRequest>(), Arg.Any<CancellationToken>())
.Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5)));
stub.GetUserNotificationsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<CancellationToken>())
.Returns(PagedResult<UserNotificationDto>.Create([], 0, 1, 20));
stub.MarkAllReadAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
stub.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(new UserNotificationDto(1, "achievement", "Title", "Body", false, DateTime.UtcNow));
return stub;
}
private static IUserService CreateUserServiceStub()
{
var stub = Substitute.For<IUserService>();
var userDto = new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow);
var pagedUsers = PagedResult<UserDto>.Create([userDto], 1, 1, 20);
var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null,
"Title", null, LectureFormat.Offline,
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
true, 30, 0, null, DateTime.UtcNow, true);
var pagedLectures = PagedResult<LectureDto>.Create([lectureDto], 1, 1, 20);
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(
0,
0,
0,
0,
0,
1,
0,
0,
100,
0,
3,
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
return stub;
}
private static ILectureService CreateLectureServiceStub()
{
var stub = Substitute.For<ILectureService>();
var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null,
"Title", null, LectureFormat.Offline,
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
true, 30, 0, null, DateTime.UtcNow);
var detailDto = new LectureDetailDto(1, 1, "Course", null, null, null, null,
"Title", null, LectureFormat.Offline,
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
true, 30, 0, null, DateTime.UtcNow, false);
var pagedLectures = PagedResult<LectureDto>.Create([lectureDto], 1, 1, 20);
var pagedEnrollments = PagedResult<EnrollmentDto>.Create([], 0, 1, 20);
stub.GetAllAsync(Arg.Any<LectureFilterRequest>(), Arg.Any<int?>()).Returns(pagedLectures);
stub.GetByIdAsync(Arg.Any<int>(), Arg.Any<int?>()).Returns(detailDto);
stub.CreateAsync(Arg.Any<CreateLectureRequest>()).Returns(lectureDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(lectureDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.EnrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.UnenrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(pagedEnrollments);
return stub;
}
private static IReviewService CreateReviewServiceStub()
{
var stub = Substitute.For<IReviewService>();
var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User",
ReviewRating.Like, "Great!", ReviewLlmStatus.Pending,
null, null, null, null, null, DateTime.UtcNow);
var pagedReviews = PagedResult<ReviewDto>.Create([reviewDto], 1, 1, 20);
stub.CreateAsync(Arg.Any<int>(), Arg.Any<CreateReviewRequest>()).Returns(reviewDto);
stub.GetByIdAsync(Arg.Any<int>()).Returns(reviewDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<UpdateReviewRequest>()).Returns(reviewDto);
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int?>(), Arg.Any<bool>()).Returns(pagedReviews);
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static IReviewPromptService CreateReviewPromptServiceStub()
{
var stub = Substitute.For<IReviewPromptService>();
var promptDto = new ReviewPromptDto(
"Analyze {lectureContext}. Review: {reviewText}",
DateTime.UtcNow);
stub.GetAsync().Returns(promptDto);
stub.UpdateAsync(Arg.Any<UpdateReviewPromptRequest>()).Returns(callInfo =>
new ReviewPromptDto(callInfo.Arg<UpdateReviewPromptRequest>().Prompt, DateTime.UtcNow));
return stub;
}
private static ICourseService CreateCourseServiceStub()
{
var stub = Substitute.For<ICourseService>();
var courseDto = new CourseDto(1, "Course", null, false, [], DateTime.UtcNow);
var paged = PagedResult<CourseDto>.Create([courseDto], 1, 1, 20);
stub.GetAllAsync(Arg.Any<CourseFilterRequest>()).Returns(paged);
stub.GetByIdAsync(Arg.Any<int>()).Returns(courseDto);
stub.CreateAsync(Arg.Any<CreateCourseRequest>()).Returns(courseDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateCourseRequest>()).Returns(courseDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.AddTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.RemoveTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static ITagService CreateTagServiceStub()
{
var stub = Substitute.For<ITagService>();
var tagDto = new TagDto(1, "Tag", TagType.Topic, null, DateTime.UtcNow);
stub.GetAllAsync(Arg.Any<TagType?>(), Arg.Any<int?>()).Returns([tagDto]);
stub.GetByIdAsync(Arg.Any<int>()).Returns(tagDto);
stub.CreateAsync(Arg.Any<CreateTagRequest>()).Returns(tagDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateTagRequest>()).Returns(tagDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.GetTreeAsync().Returns(new List<TagTreeDto>());
return stub;
}
private static ILocationService CreateLocationServiceStub()
{
var stub = Substitute.For<ILocationService>();
var locationDto = new LocationDto(1, "Room 101", null, null, null, DateTime.UtcNow);
stub.GetAllAsync().Returns([locationDto]);
stub.GetByIdAsync(Arg.Any<int>()).Returns(locationDto);
stub.CreateAsync(Arg.Any<CreateLocationRequest>()).Returns(locationDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLocationRequest>()).Returns(locationDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static IAchievementService CreateAchievementServiceStub()
{
var stub = Substitute.For<IAchievementService>();
var achievementDto = new AchievementDto(1, "First Review", null, null, 10, 5, null, DateTime.UtcNow);
stub.GetAllAsync().Returns([achievementDto]);
stub.GetByIdAsync(Arg.Any<int>()).Returns(achievementDto);
stub.CreateAsync(Arg.Any<CreateAchievementRequest>()).Returns(achievementDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateAchievementRequest>()).Returns(achievementDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static IGamificationService CreateGamificationServiceStub()
{
var stub = Substitute.For<IGamificationService>();
var paged = PagedResult<CoinTransactionDto>.Create([], 0, 1, 20);
stub.GetUserAchievementsAsync(Arg.Any<int>()).Returns(new List<UserAchievementDto>());
stub.GetTransactionsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(paged);
stub.AwardCoinsAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CoinTransactionType>(),
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>()).Returns(Task.CompletedTask);
stub.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.CalculateLevelAsync(Arg.Any<int>()).Returns(Task.FromResult(1));
stub.GetLevelProgressAsync(Arg.Any<int>()).Returns(Task.FromResult(new LevelProgressDto(0, 100)));
return stub;
}
private static IScheduleSyncService CreateSyncServiceStub()
{
var stub = Substitute.For<IScheduleSyncService>();
var syncResult = new SyncResultDto(0, 0, 0, null);
var syncStatus = new SyncStatusDto(null, "idle", null);
stub.SyncScheduleAsync(Arg.Any<SyncScheduleRequest>()).Returns(syncResult);
stub.SyncRoomsAsync().Returns(syncResult);
stub.SearchEmployeesAsync(Arg.Any<string>()).Returns(new List<EmployeeDto>());
stub.GetLastSyncStatusAsync().Returns(syncStatus);
return stub;
}
}
@@ -0,0 +1,44 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace UniVerse.Api.Tests.Helpers;
/// <summary>
/// Генерирует подписанные JWT токены для использования в интеграционных тестах.
/// Использует те же секрет/издателя/аудиторию (secret/issuer/audience), которые внедряет ApiWebApplicationFactory.
/// </summary>
public static class TestJwtFactory
{
public const string Secret = "test-super-secret-key-32-chars!!";
public const string Issuer = "UniVerse-Test";
public const string Audience = "UniVerse-Test";
/// <summary>Создает валидную строку токена JWT (bearer) для заданной роли и идентификатора пользователя.</summary>
public static string Generate(string role, int userId = 1)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim(ClaimTypes.Role, role),
new Claim("sub", userId.ToString()),
};
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Создает значение заголовка Authorization: "Bearer &lt;token&gt;".</summary>
public static string BearerHeader(string role, int userId = 1)
=> $"Bearer {Generate(role, userId)}";
}
@@ -0,0 +1,241 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Lectures;
public class LectureServiceTests
{
[Fact]
public async Task GetAllAsync_MarksLecturesEnrolledByCurrentUser()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
var startsAt = DateTime.UtcNow.AddDays(1);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, startsAt),
Lecture(2, startsAt.AddDays(1)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
await db.SaveChangesAsync();
var result = await service.GetAllAsync(new LectureFilterRequest(null, null, null, null, null, null, null, null), 1);
Assert.True(result.Items.Single(item => item.Id == 1).IsEnrolled);
Assert.False(result.Items.Single(item => item.Id == 2).IsEnrolled);
}
[Fact]
public async Task EnrollAsync_SchedulesLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddHours(4);
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 3 часа")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-3))),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 1 час")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-1))),
"lecture-1-user-1-starts-in-1-hour",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("Оцените")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(2))),
"lecture-1-user-1-ended",
Arg.Any<CancellationToken>());
}
[Fact]
public async Task EnrollAsync_SkipsPastLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddMinutes(90);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await scheduler.DidNotReceive().ScheduleAsync(
Arg.Any<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(2).ScheduleAsync(
Arg.Any<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Theory]
[InlineData(1, 3)]
[InlineData(2, 3)]
[InlineData(3, 5)]
[InlineData(4, 7)]
[InlineData(5, 7)]
public async Task EnrollAsync_ThrowsWhenActiveEnrollmentLimitReached(int level, int activeEnrollments)
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(level);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var startsAt = DateTime.UtcNow.AddDays(1);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, startsAt.AddDays(100)));
for (var i = 1; i <= activeEnrollments; i++)
{
db.Lectures.Add(Lecture(i, startsAt.AddDays(i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit()
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit()
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1, Attended = true });
}
await db.SaveChangesAsync();
await service.EnrollAsync(100, 1);
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
}
[Fact]
public async Task UnenrollAsync_CancelsLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddHours(4);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
await db.SaveChangesAsync();
await service.UnenrollAsync(1, 1);
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-3-hours", Arg.Any<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-1-hour", Arg.Any<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_TeacherCannotUpdateAnotherTeachersLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var request = new UpdateLectureRequest(null, null, "Updated", null, Domain.Enums.LectureFormat.Offline,
DateTime.UtcNow.AddDays(1), DateTime.UtcNow.AddDays(1).AddHours(2), true, 30, null);
await Assert.ThrowsAsync<ForbiddenException>(() => service.UpdateAsync(1, request, currentUserId: 1));
}
[Fact]
public async Task GetEnrollmentsAsync_AdminCanReadAnyLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var result = await service.GetEnrollmentsAsync(1, new UniVerse.Application.DTOs.Common.PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Empty(result.Items);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"LectureServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 30
};
}
@@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class LlmAnalysisServiceTests
{
[Fact]
public async Task AnalyzeReviewAsync_SavesParsedAnalysisResult()
{
await using var db = CreateDbContext();
await SeedPendingReviewAsync(db);
var llm = Substitute.For<ILlmClient>();
llm.AnalyzeReviewAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(new LlmReviewAnalysis(
0.76,
"Положительный",
["lecture structure", "practical examples"],
true,
"{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}"));
var gamification = Substitute.For<IGamificationService>();
gamification.AwardCoinsAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CoinTransactionType>(),
Arg.Any<int?>(),
Arg.Any<int?>(),
Arg.Any<string?>())
.Returns(Task.CompletedTask);
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
var service = new LlmAnalysisService(db, llm, gamification, NullLogger<LlmAnalysisService>.Instance);
await service.AnalyzeReviewAsync(1);
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Analyzed, review.LlmStatus);
Assert.Equal(ReviewSentiment.Positive, review.Sentiment);
Assert.Equal(0.76, review.QualityScore);
Assert.True(review.IsInformative);
Assert.Equal(["lecture structure", "practical examples"], review.LlmTags!);
Assert.Equal("{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}", review.LlmRawOutput);
await gamification.Received(1).AwardCoinsAsync(
1,
10,
CoinTransactionType.ReviewReward,
1,
null,
"Informative review reward");
}
private static async Task SeedPendingReviewAsync(AppDbContext db)
{
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
Title = "Lecture",
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
IsOpen = true,
MaxEnrollments = 30
});
db.Reviews.Add(new Review
{
Id = 1,
LectureId = 1,
UserId = 1,
Rating = ReviewRating.Like,
Text = "Useful review",
LlmStatus = ReviewLlmStatus.Pending
});
await db.SaveChangesAsync();
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"LlmAnalysisServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Infrastructure.Data;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewAnalysisWorkerTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task Worker_DoesNotExceedConfiguredConcurrency(int maxConcurrentProcessing)
{
var queue = new ReviewAnalysisQueue();
var analysisService = new RecordingLlmAnalysisService();
await using var provider = CreateServiceProvider(analysisService);
var worker = new ReviewAnalysisWorker(
provider,
queue,
Microsoft.Extensions.Options.Options.Create(
new ReviewAnalysisOptions { MaxConcurrentProcessing = maxConcurrentProcessing }),
NullLogger<ReviewAnalysisWorker>.Instance);
for (var reviewId = 1; reviewId <= 6; reviewId++)
await queue.EnqueueAsync(reviewId);
analysisService.ExpectProcessed(6);
await worker.StartAsync(CancellationToken.None);
await analysisService.WaitForProcessedAsync();
await worker.StopAsync(CancellationToken.None);
Assert.True(
analysisService.MaxRunning <= maxConcurrentProcessing,
$"Expected at most {maxConcurrentProcessing} concurrent analyses, got {analysisService.MaxRunning}.");
}
private static ServiceProvider CreateServiceProvider(ILlmAnalysisService analysisService)
{
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"ReviewAnalysisWorkerTests_{Guid.NewGuid()}"));
services.AddScoped(_ => analysisService);
return services.BuildServiceProvider();
}
private sealed class RecordingLlmAnalysisService : ILlmAnalysisService
{
private readonly TaskCompletionSource _processedAll = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _expectedCount;
private int _processedCount;
private int _running;
private int _maxRunning;
public int MaxRunning => _maxRunning;
public void ExpectProcessed(int expectedCount)
{
Volatile.Write(ref _expectedCount, expectedCount);
}
public async Task AnalyzeReviewAsync(int reviewId)
{
var running = Interlocked.Increment(ref _running);
UpdateMaxRunning(running);
await Task.Delay(50);
Interlocked.Decrement(ref _running);
if (Interlocked.Increment(ref _processedCount) >= Volatile.Read(ref _expectedCount))
_processedAll.TrySetResult();
}
public async Task WaitForProcessedAsync()
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var registration = timeout.Token.Register(() => _processedAll.TrySetCanceled(timeout.Token));
await _processedAll.Task;
}
private void UpdateMaxRunning(int running)
{
while (true)
{
var current = Volatile.Read(ref _maxRunning);
if (running <= current) return;
if (Interlocked.CompareExchange(ref _maxRunning, running, current) == current) return;
}
}
}
}
@@ -0,0 +1,184 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.Interfaces;
using UniVerse.Application.Prompts;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.ExternalServices;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewPromptServiceTests
{
[Fact]
public async Task GetAsync_ReturnsDefaultPrompt_WhenSettingDoesNotExist()
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
var result = await service.GetAsync();
Assert.Equal(ReviewPromptTemplate.Default, result.Prompt);
Assert.Null(result.UpdatedAt);
}
[Fact]
public async Task UpdateAsync_UpsertsSingletonPrompt()
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
await service.UpdateAsync(new UpdateReviewPromptRequest("First {lectureContext} {reviewText}"));
var result = await service.UpdateAsync(new UpdateReviewPromptRequest("Second {lectureContext} {reviewText}"));
Assert.Equal("Second {lectureContext} {reviewText}", result.Prompt);
Assert.NotNull(result.UpdatedAt);
Assert.Equal(1, await db.ReviewPromptSettings.CountAsync());
Assert.Equal("Second {lectureContext} {reviewText}", (await service.GetAsync()).Prompt);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Prompt without placeholders")]
[InlineData("Only lecture {lectureContext}")]
[InlineData("Only review {reviewText}")]
public async Task UpdateAsync_RejectsInvalidPrompt(string prompt)
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
await Assert.ThrowsAsync<BadRequestException>(() =>
service.UpdateAsync(new UpdateReviewPromptRequest(prompt)));
}
[Fact]
public async Task AnalyzeReviewAsync_RendersCustomPrompt()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://llm.test/")
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(
"Custom prompt. Context: {lectureContext}. Text: {reviewText}",
DateTime.UtcNow));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
Assert.NotNull(handler.RequestBody);
using var requestJson = JsonDocument.Parse(handler.RequestBody!);
var content = requestJson.RootElement
.GetProperty("messages")[0]
.GetProperty("content")
.GetString();
Assert.Contains("Custom prompt", content);
Assert.Contains("Lecture: Algebra", content);
Assert.Contains("Very useful review", content);
Assert.DoesNotContain(ReviewPromptTemplate.LectureContextPlaceholder, content);
Assert.DoesNotContain(ReviewPromptTemplate.ReviewTextPlaceholder, content);
}
[Fact]
public async Task AnalyzeReviewAsync_ParsesSnakeCaseJsonFromFencedResponse()
{
var handler = new CapturingHandler("""
```json
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
```
""");
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://llm.test/")
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
var result = await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
Assert.Equal(0.82, result.QualityScore);
Assert.Equal("Положительный", result.Sentiment);
Assert.Equal(["lecture structure", "practical examples"], result.Tags);
Assert.True(result.IsInformative);
Assert.Equal("""
```json
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
```
""", result.RawOutput);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private sealed class CapturingHandler : HttpMessageHandler
{
private readonly string _analysisContent;
public CapturingHandler(string? analysisContent = null)
{
_analysisContent = analysisContent ??
"{\"quality_score\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"is_informative\":true}";
}
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
var responsePayload = JsonSerializer.Serialize(new
{
choices = new[]
{
new
{
message = new
{
content = _analysisContent
}
}
}
});
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responsePayload, Encoding.UTF8, "application/json")
};
}
}
}
@@ -0,0 +1,145 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewServiceTests
{
[Fact]
public async Task CreateAsync_EnqueuesReviewAnalysis()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedLectureAsync(db);
var result = await service.CreateAsync(1, new CreateReviewRequest(1, ReviewRating.Like, "Great lecture"));
await queue.Received(1).EnqueueAsync(result.Id, Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedAnalyzedReviewAsync(db);
await service.UpdateAsync(1, 1, new UpdateReviewRequest(ReviewRating.Neutral, "Updated text"));
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
Assert.Null(review.Sentiment);
Assert.Null(review.QualityScore);
Assert.Null(review.IsInformative);
Assert.Null(review.LlmTags);
Assert.Null(review.LlmRawOutput);
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ReanalyzeAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedAnalyzedReviewAsync(db);
await service.ReanalyzeAsync(1);
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
Assert.Null(review.Sentiment);
Assert.Null(review.QualityScore);
Assert.Null(review.IsInformative);
Assert.Null(review.LlmTags);
Assert.Null(review.LlmRawOutput);
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetByLectureAsync_TeacherCannotReadAnotherTeachersReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
await Assert.ThrowsAsync<ForbiddenException>(() =>
service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1));
}
[Fact]
public async Task GetByLectureAsync_AdminCanReadAnyLectureReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
var result = await service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Single(result.Items);
}
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
{
var gamification = Substitute.For<IGamificationService>();
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return new ReviewService(db, gamification, queue);
}
private static async Task SeedLectureAsync(AppDbContext db, int? teacherId = null)
{
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
TeacherId = teacherId,
Title = "Lecture",
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
IsOpen = true,
MaxEnrollments = 30
});
await db.SaveChangesAsync();
}
private static async Task SeedAnalyzedReviewAsync(AppDbContext db, int? teacherId = null)
{
await SeedLectureAsync(db, teacherId);
db.Reviews.Add(new Review
{
Id = 1,
LectureId = 1,
UserId = 1,
Rating = ReviewRating.Like,
Text = "Original text",
LlmStatus = ReviewLlmStatus.Analyzed,
Sentiment = ReviewSentiment.Positive,
QualityScore = 0.9,
IsInformative = true,
LlmTags = ["clear"],
LlmRawOutput = "{\"quality_score\":0.9}"
});
await db.SaveChangesAsync();
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ReviewServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -0,0 +1,49 @@
using System.Net;
using System.Text.Json;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.Swagger;
public class SwaggerDocumentTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
public SwaggerDocumentTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task SwaggerJson_IsGenerated()
{
var response = await _client.GetAsync("api/docs/v1/swagger.json");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = document.RootElement;
Assert.Equal("UniVerse API", root.GetProperty("info").GetProperty("title").GetString());
Assert.True(root.GetProperty("components").GetProperty("securitySchemes").TryGetProperty("Bearer", out _));
}
[Fact]
public async Task SwaggerJson_DocumentsSecurityOnlyForAuthorizedEndpoints()
{
using var document = JsonDocument.Parse(await _client.GetStringAsync("api/docs/v1/swagger.json"));
var paths = document.RootElement.GetProperty("paths");
var publicOperation = paths
.GetProperty("/api/v1/auth/login/dev")
.GetProperty("post");
var protectedOperation = paths
.GetProperty("/api/v1/users")
.GetProperty("get");
Assert.False(publicOperation.TryGetProperty("security", out _));
Assert.True(protectedOperation.TryGetProperty("security", out var security));
Assert.Equal("Bearer", security[0].EnumerateObject().Single().Name);
Assert.Contains("Required roles:", protectedOperation.GetProperty("description").GetString());
}
}
@@ -0,0 +1,375 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ScheduleSyncServiceTests
{
private const string EventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a";
private const string CourseId = "73aa6226-adbb-4e15-b264-e16fee19fd73";
private const string PersonId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440";
private const string FullName = "Иванов Иван Иванович";
[Fact]
public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats()
{
await using var db = CreateDbContext();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
.Returns(new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = "event-1",
Name = "Open lecture",
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc)
}
],
EventRooms =
[
new ModeusEventRoom
{
Links = new ModeusEventRoomLinks
{
Event = new ModeusHrefLink("/events/event-1"),
Room = new ModeusHrefLink("/rooms/room-1")
}
}
],
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 42)
],
EventTeams =
[
new ModeusEventTeam("event-1", 15)
]
}
});
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
var lecture = await db.Lectures.SingleAsync();
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
Assert.Equal(42, lecture.MaxEnrollments);
}
[Fact]
public async Task SyncScheduleAsync_LoadsRoomCapacityWhenEventRoomHasNoCapacity()
{
await using var db = CreateDbContext();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
.Returns(new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = "event-1",
Name = "Open lecture",
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc)
}
],
EventRooms =
[
new ModeusEventRoom
{
Links = new ModeusEventRoomLinks
{
Event = new ModeusHrefLink("/events/event-1"),
Room = new ModeusHrefLink("/rooms/room-1")
}
}
],
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: null, WorkingCapacity: null)
],
EventTeams =
[
new ModeusEventTeam("event-1", 15)
]
}
});
modeus.SearchRoomsAsync()
.Returns(new ModeusRoomsResponse
{
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 48)
]
});
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
var lecture = await db.Lectures.SingleAsync();
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
Assert.Equal(48, lecture.MaxEnrollments);
}
[Fact]
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse());
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
var lecture = await db.Lectures.Include(item => item.Teacher).SingleAsync();
Assert.Equal("Иванов Иван Иванович", lecture.Teacher?.DisplayName);
Assert.Equal("modeus-b5a5cad8-60c2-4d94-9972-8a0c2e981440@modeus.local", lecture.Teacher?.Email);
var teacherProfile = await db.TeacherProfiles.Include(item => item.User).SingleAsync();
Assert.Equal("b5a5cad8-60c2-4d94-9972-8a0c2e981440", teacherProfile.ModeusId);
Assert.Equal(teacherProfile.UserId, lecture.TeacherId);
var teacherRole = await db.UserRoles.SingleAsync();
Assert.Equal(lecture.TeacherId, teacherRole.UserId);
Assert.Equal(UserRole.Teacher, teacherRole.Role);
}
[Fact]
public async Task SyncScheduleAsync_SavesResolvedTeacherSubId()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal("sso-sub-1", teacher.MicrosoftId);
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
}
[Fact]
public async Task SyncScheduleAsync_UsesPlaceholderWhenSubLookupFails()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), throwOnSubLookup: true);
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
Assert.Null(teacher.MicrosoftId);
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
}
[Fact]
public async Task SyncScheduleAsync_AttachesTeacherProfileToExistingSsoUser()
{
await using var db = CreateDbContext();
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 77,
Email = "teacher@sfedu.ru",
DisplayName = "Old Name",
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 77, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Single(await db.Users.ToListAsync());
var teacher = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal(77, teacher.Id);
Assert.Equal("teacher@sfedu.ru", teacher.Email);
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Student);
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Teacher);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
Assert.True(await db.Lectures.AnyAsync(lecture => lecture.TeacherId == 77));
}
[Fact]
public async Task SyncScheduleAsync_MergesPlaceholderIntoExistingSsoUserOnRetry()
{
await using var db = CreateDbContext();
var placeholder = new UniVerse.Domain.Entities.User
{
Id = 10,
Email = $"modeus-{PersonId}@modeus.local",
DisplayName = FullName,
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
};
db.Users.Add(placeholder);
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 20,
Email = "teacher@sfedu.ru",
DisplayName = FullName,
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 20, Role = UserRole.Student }]
});
db.Courses.Add(new UniVerse.Domain.Entities.Course { Id = 1, Name = "Course", ExternalId = CourseId, IsSynced = true });
db.Lectures.Add(new UniVerse.Domain.Entities.Lecture
{
Id = 1,
CourseId = 1,
TeacherId = 10,
ExternalId = EventId,
Title = "Old",
StartsAt = DateTime.UtcNow,
EndsAt = DateTime.UtcNow.AddHours(1)
});
await db.SaveChangesAsync();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Single(await db.Users.ToListAsync());
var realUser = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal(20, realUser.Id);
Assert.Equal(PersonId, realUser.TeacherProfile?.ModeusId);
Assert.Contains(realUser.Roles, role => role.Role == UserRole.Teacher);
Assert.True(await db.Lectures.AllAsync(lecture => lecture.TeacherId == 20));
}
[Fact]
public async Task SyncScheduleAsync_DoesNotLookupSubWhenTeacherAlreadyHasMicrosoftId()
{
await using var db = CreateDbContext();
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 10,
Email = "teacher@sfedu.ru",
DisplayName = FullName,
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
});
await db.SaveChangesAsync();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>()).Returns(BuildEventsResponse());
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static ModeusEventsResponse BuildEventsResponse()
{
const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7";
return new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = EventId,
Name = "Тема 20. Управление ресурсами проекта. Часть 2.",
TypeId = "LAB",
StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc),
Links = new ModeusEventLinks
{
CourseUnitRealization = new ModeusHrefLink($"/{CourseId}")
}
}
],
CourseUnitRealizations =
[
new ModeusCourseUnitRealization(
CourseId,
"Управление проектами разработки программного обеспечения",
"УПРПО")
],
EventTeams = [new ModeusEventTeam(EventId, 25)],
EventAttendees =
[
new ModeusEventAttendee
{
Id = attendeeId,
RoleId = "TEACH",
RoleName = "Преподаватель",
Links = new ModeusEventAttendeeLinks
{
Event = new ModeusHrefLink($"/{EventId}"),
Person = new ModeusHrefLink($"/{PersonId}")
}
}
],
Persons =
[
new ModeusPerson(
PersonId,
"Иванов",
"Иван",
"Иванович",
FullName)
]
}
};
}
private sealed class FakeModeusApiClient(
ModeusEventsResponse events,
string? subId = null,
bool throwOnSubLookup = false) : IModeusApiClient
{
public Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
public Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
{
if (throwOnSubLookup)
throw new HttpRequestException("lookup failed");
return Task.FromResult(subId);
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Users;
public class UserServiceTests
{
[Fact]
public async Task GetStatsAsync_ReturnsLevelProgressThresholds()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 120 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(2, stats.Level);
Assert.Equal(100, stats.CurrentLevelXp);
Assert.Equal(300, stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsNullNextLevelAtMaxConfiguredLevel()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.Level);
Assert.Equal(300, stats.CurrentLevelXp);
Assert.Null(stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsEnrollmentSlotStateAndRules()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, now.AddDays(1)),
Lecture(2, now.AddDays(2)),
Lecture(3, now.AddDays(-1)));
db.LectureEnrollments.AddRange(
new LectureEnrollment { LectureId = 1, UserId = 1 },
new LectureEnrollment { LectureId = 2, UserId = 1 },
new LectureEnrollment { LectureId = 3, UserId = 1 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.ActiveEnrollments);
Assert.Equal(5, stats.EnrollmentSlotLimit);
Assert.Equal(new[] { 1, 3, 4 }, stats.EnrollmentSlotRules.Select(rule => rule.Level));
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots));
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"UserServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static UserService CreateService(AppDbContext db)
{
var notifications = Substitute.For<INotificationService>();
notifications.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
return new UserService(db, gamification);
}
private static void SeedLevelThresholds(AppDbContext db)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 30
};
}
@@ -0,0 +1,17 @@
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.BackgroundServices;
public class AchievementCatalogHostedService : IHostedService
{
private readonly IServiceProvider _services;
public AchievementCatalogHostedService(IServiceProvider services) => _services = services;
public async Task StartAsync(CancellationToken cancellationToken)
{
await AchievementCatalogSeeder.SeedAsync(_services, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -1,36 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.BackgroundServices;
public class LlmProcessingBackgroundService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<LlmProcessingBackgroundService> _logger;
public LlmProcessingBackgroundService(IServiceProvider services, ILogger<LlmProcessingBackgroundService> logger)
{
_services = services; _logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("LLM Processing Background Service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
await llmService.ProcessPendingReviewsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in LLM processing background service");
}
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
}
}
@@ -0,0 +1,21 @@
using System.Threading.Channels;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.BackgroundServices;
public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue
{
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(reviewId, cancellationToken);
}
public IAsyncEnumerable<int> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
@@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.BackgroundServices;
public sealed class ReviewAnalysisWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ReviewAnalysisQueue _queue;
private readonly ReviewAnalysisOptions _options;
private readonly ILogger<ReviewAnalysisWorker> _logger;
public ReviewAnalysisWorker(
IServiceProvider services,
ReviewAnalysisQueue queue,
IOptions<ReviewAnalysisOptions> options,
ILogger<ReviewAnalysisWorker> logger)
{
_services = services;
_queue = queue;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var maxConcurrency = Math.Max(1, _options.MaxConcurrentProcessing);
_logger.LogInformation(
"Review analysis worker started with max concurrency {MaxConcurrency}",
maxConcurrency);
await EnqueueExistingPendingReviewsAsync(stoppingToken);
var workers = Enumerable.Range(1, maxConcurrency)
.Select(workerNumber => ProcessQueueAsync(workerNumber, stoppingToken))
.ToArray();
try
{
await Task.WhenAll(workers);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Review analysis worker stopped");
}
}
private async Task EnqueueExistingPendingReviewsAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pendingReviewIds = await db.Reviews
.Where(r => r.LlmStatus == ReviewLlmStatus.Pending)
.OrderBy(r => r.CreatedAt)
.Select(r => r.Id)
.ToListAsync(cancellationToken);
foreach (var reviewId in pendingReviewIds)
await _queue.EnqueueAsync(reviewId, cancellationToken);
if (pendingReviewIds.Count > 0)
_logger.LogInformation(
"Queued {ReviewCount} pending reviews for immediate analysis",
pendingReviewIds.Count);
}
private async Task ProcessQueueAsync(int workerNumber, CancellationToken cancellationToken)
{
await foreach (var reviewId in _queue.ReadAllAsync(cancellationToken))
{
try
{
using var scope = _services.CreateScope();
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
await llmService.AnalyzeReviewAsync(reviewId);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Review analysis worker {WorkerNumber} failed to process review {ReviewId}",
workerNumber,
reviewId);
}
}
}
}
@@ -5,31 +5,87 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Управление определениями достижений системы геймификации.</summary>
[ApiController]
[Route("api/v1/achievements")]
[Authorize]
[Produces("application/json")]
public class AchievementsController : ControllerBase
{
private readonly IAchievementService _achievements;
public AchievementsController(IAchievementService achievements) => _achievements = achievements;
/// <summary>Получить список всех достижений.</summary>
/// <remarks>Возвращает определения достижений (без информации о получении конкретным пользователем).
/// Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements.</remarks>
/// <response code="200">Список достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(List<AchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll() => Ok(await _achievements.GetAllAsync());
/// <summary>Получить достижение по ID.</summary>
/// <param name="id">ID достижения.</param>
/// <response code="200">Данные достижения.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Достижение не найдено.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AchievementDto>> Get(int id) => Ok(await _achievements.GetByIdAsync(id));
/// <summary>Создать новое достижение.</summary>
/// <remarks>Только Admin. Достижения автоматически присваиваются студентам при выполнении условий.</remarks>
/// <param name="req">Название, описание, иконка, награда в XP/монетах и условие получения.</param>
/// <response code="201">Достижение создано.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req));
/// <summary>Обновить достижение по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID достижения.</param>
/// <param name="req">Обновляемые поля достижения.</param>
/// <response code="200">Обновлённые данные достижения.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Достижение не найдено.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AchievementDto>> Update(int id, [FromBody] UpdateAchievementRequest req) =>
Ok(await _achievements.UpdateAsync(id, req));
/// <summary>Удалить достижение по ID.</summary>
/// <remarks>
/// Только Admin. Удаление не отзывает достижение у уже получивших его пользователей.
/// </remarks>
/// <param name="id">ID достижения.</param>
/// <response code="204">Достижение удалено.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Достижение не найдено.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _achievements.DeleteAsync(id);
return NoContent();
}
}
@@ -1,37 +1,208 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using UniVerse.Application.DTOs.Auth;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using System.Security.Cryptography;
using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Аутентификация и управление сессией пользователя.</summary>
[ApiController]
[Route("api/v1/auth")]
[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
public AuthController(IAuthService auth) => _auth = auth;
private readonly IConfiguration _config;
private const string MicrosoftStateCookieName = "msAuthState";
private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl";
public AuthController(IAuthService auth, IConfiguration config)
{
_auth = auth;
_config = config;
}
/// <summary>Вход через Microsoft Entra ID (SPA/PKCE flow).</summary>
/// <remarks>
/// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
/// полученный authorization code. В ответ возвращается пара JWT-токенов;
/// refresh token устанавливается в HttpOnly cookie.
/// </remarks>
/// <param name="request">Authorization code и redirect URI из Microsoft OAuth2.</param>
/// <response code="200">Успешный вход — возвращает access token и данные пользователя.</response>
/// <response code="400">Неверный или просроченный authorization code.</response>
[HttpPost("login/microsoft")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
{
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode);
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken);
return Ok(result.Response);
}
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
/// <remarks>
/// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
/// и редиректит пользователя на `login.microsoftonline.com`.
/// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
/// </remarks>
/// <param name="returnUrl">URL для редиректа после успешного входа (опционально).</param>
/// <response code="302">Редирект на Microsoft authorize endpoint.</response>
/// <response code="500">Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).</response>
[HttpGet("login/microsoft")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
{
var tenantId = _config["AzureAd:TenantId"];
var clientId = _config["AzureAd:ClientId"];
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
return Problem("Microsoft authentication is not configured (AzureAd:TenantId/ClientId).", statusCode: StatusCodes.Status500InternalServerError);
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
{
Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
}
var authorizeEndpoint = $"{instance.TrimEnd('/')}/{tenantId}/oauth2/v2.0/authorize";
var scope = _config["AzureAd:Scopes"] ?? "openid profile email offline_access User.Read";
var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary<string, string?>
{
["client_id"] = clientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri,
["response_mode"] = "query",
["scope"] = scope,
["state"] = state
});
return Redirect(authorizeUrl);
}
/// <summary>OAuth2 callback — обмен code на токены (server-driven flow).</summary>
/// <remarks>
/// Microsoft редиректит браузер сюда после успешного входа.
/// Backend валидирует CSRF state, обменивает code на токены,
/// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
/// </remarks>
/// <param name="code">Authorization code от Microsoft.</param>
/// <param name="state">CSRF state для верификации.</param>
/// <param name="error">Код ошибки от Microsoft (если вход не удался).</param>
/// <param name="errorDescription">Описание ошибки от Microsoft.</param>
/// <response code="302">Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.</response>
/// <response code="200">Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).</response>
/// <response code="400">Отсутствует authorization code.</response>
/// <response code="401">Ошибка от Microsoft или невалидный CSRF state.</response>
[HttpGet("callback/microsoft")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> CallbackMicrosoft(
[FromQuery] string? code = null,
[FromQuery] string? state = null,
[FromQuery] string? error = null,
[FromQuery(Name = "error_description")] string? errorDescription = null)
{
if (!string.IsNullOrEmpty(error))
{
return Unauthorized(new
{
error,
errorDescription
});
}
if (string.IsNullOrWhiteSpace(code))
return BadRequest(new { error = "missing_code" });
var expectedState = Request.Cookies[MicrosoftStateCookieName];
if (string.IsNullOrWhiteSpace(expectedState) || string.IsNullOrWhiteSpace(state) || !string.Equals(expectedState, state, StringComparison.Ordinal))
return Unauthorized(new { error = "invalid_state" });
Response.Cookies.Delete(MicrosoftStateCookieName);
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken);
var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"];
Response.Cookies.Delete(MicrosoftReturnUrlCookieName);
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
{
// Put access token in URL fragment so it is not sent as Referer to the backend.
// Frontend can read it from location.hash on the landing page.
var fragment = $"access_token={Uri.EscapeDataString(result.Response.AccessToken)}&expires_at={Uri.EscapeDataString(result.Response.ExpiresAt.ToString("O"))}";
return Redirect($"{returnUrl}#{fragment}");
}
// Useful for manual testing without frontend: you'll see JSON in the browser.
return Ok(result.Response);
}
/// <summary>Dev-only вход без OAuth (только в Development-окружении).</summary>
/// <remarks>
/// Создаёт или находит пользователя по email без реального OAuth flow.
/// Возвращает 404 в Production и Staging.
/// </remarks>
/// <param name="request">Email, отображаемое имя и роль тестового пользователя.</param>
/// <response code="200">Успешный вход.</response>
/// <response code="404">Endpoint недоступен вне Development.</response>
[HttpPost("login/dev")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
{
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
return NotFound();
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role);
var roles = request.Roles?.Count > 0 ? request.Roles : [UserRole.Student];
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, roles, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken);
return Ok(result.Response);
}
/// <summary>Обновление access token по refresh token из HttpOnly cookie.</summary>
/// <remarks>
/// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
/// Возвращает новую пару токенов и обновляет cookie.
/// </remarks>
/// <response code="200">Новая пара токенов.</response>
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
[HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthResponse>> Refresh()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -41,8 +212,17 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
/// <summary>Выход из системы — отзыв refresh token.</summary>
/// <remarks>
/// Инвалидирует текущий refresh token в БД и удаляет cookie.
/// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
/// </remarks>
/// <response code="204">Выход выполнен успешно.</response>
/// <response code="401">Требуется аутентификация.</response>
[Authorize]
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Logout()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -52,8 +232,16 @@ public class AuthController : ControllerBase
return NoContent();
}
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
/// <response code="200">Данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
[Authorize]
[HttpGet("me")]
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Me()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -62,6 +250,21 @@ public class AuthController : ControllerBase
return Ok(user);
}
private string? GetClientIpAddress()
{
if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var firstForwardedAddress = forwardedFor.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(firstForwardedAddress))
return firstForwardedAddress;
}
if (Request.Headers.TryGetValue("X-Real-IP", out var realIp) && !string.IsNullOrWhiteSpace(realIp))
return realIp;
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private void SetRefreshTokenCookie(string token)
{
Response.Cookies.Append("refreshToken", token, new CookieOptions
@@ -70,4 +273,32 @@ public class AuthController : ControllerBase
Expires = DateTime.UtcNow.AddDays(30)
});
}
private string BuildAbsoluteUrl(string path)
{
if (!path.StartsWith('/')) path = "/" + path;
return $"{Request.Scheme}://{Request.Host}{path}";
}
private bool IsAllowedReturnUrl(string returnUrl)
{
if (Uri.TryCreate(returnUrl, UriKind.Relative, out _))
return true;
if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute))
return false;
var allowedOrigins = _config.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
foreach (var origin in allowedOrigins)
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var allowed))
continue;
if (string.Equals(allowed.Scheme, absolute.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(allowed.Host, absolute.Host, StringComparison.OrdinalIgnoreCase)
&& allowed.Port == absolute.Port)
return true;
}
return false;
}
}
@@ -1,46 +1,132 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Courses;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Управление курсами (дисциплинами) и их тегами.</summary>
[ApiController]
[Route("api/v1/courses")]
[Authorize]
[Produces("application/json")]
public class CoursesController : ControllerBase
{
private readonly ICourseService _courses;
public CoursesController(ICourseService courses) => _courses = courses;
/// <summary>Получить список курсов с фильтрацией и пагинацией.</summary>
/// <param name="filter">Фильтры: tagId, search, isSynced; параметры пагинации.</param>
/// <response code="200">Список курсов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<CourseDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
Ok(await _courses.GetAllAsync(filter));
/// <summary>Получить курс по ID (включая теги).</summary>
/// <param name="id">ID курса.</param>
/// <response code="200">Данные курса с тегами.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Курс не найден.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CourseDto>> Get(int id) => Ok(await _courses.GetByIdAsync(id));
/// <summary>Создать новый курс.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="req">Название и описание курса.</param>
/// <response code="201">Курс создан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req));
/// <summary>Обновить курс по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID курса.</param>
/// <param name="req">Новое название и/или описание.</param>
/// <response code="200">Обновлённые данные курса.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CourseDto>> Update(int id, [FromBody] UpdateCourseRequest req) =>
Ok(await _courses.UpdateAsync(id, req));
/// <summary>Удалить курс по ID.</summary>
/// <remarks>Только Admin. Удаление курса каскадно удаляет связанные лекции.</remarks>
/// <param name="id">ID курса.</param>
/// <response code="204">Курс удалён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс не найден.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _courses.DeleteAsync(id);
return NoContent();
}
/// <summary>Привязать тег к курсу.</summary>
/// <remarks>Только Admin. Тег должен существовать в системе.</remarks>
/// <param name="id">ID курса.</param>
/// <param name="tagId">ID тега.</param>
/// <response code="204">Тег привязан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс или тег не найден.</response>
/// <response code="409">Тег уже привязан к курсу.</response>
[Authorize(Roles = "Admin")]
[HttpPost("{id:int}/tags")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> AddTag(int id, [FromBody] int tagId)
{ await _courses.AddTagAsync(id, tagId); return NoContent(); }
{
await _courses.AddTagAsync(id, tagId);
return NoContent();
}
/// <summary>Отвязать тег от курса.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID курса.</param>
/// <param name="tagId">ID тега.</param>
/// <response code="204">Тег отвязан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс или тег не найден, либо связь не существует.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}/tags/{tagId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveTag(int id, int tagId)
{ await _courses.RemoveTagAsync(id, tagId); return NoContent(); }
{
await _courses.RemoveTagAsync(id, tagId);
return NoContent();
}
}
@@ -7,59 +7,203 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Каталог лекций — просмотр, управление, запись и отзывы.</summary>
[ApiController]
[Route("api/v1/lectures")]
[Authorize]
[Produces("application/json")]
public class LecturesController : ControllerBase
{
private readonly ILectureService _lectures;
private readonly IReviewService _reviews;
public LecturesController(ILectureService lectures, IReviewService reviews)
{ _lectures = lectures; _reviews = reviews; }
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
{
_lectures = lectures;
_reviews = reviews;
}
private int CurrentUserId => int.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
private bool CurrentUserIsAdmin => User.IsInRole("Admin");
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
/// <param name="filter">
/// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline),
/// isOpen, tagId, search; параметры пагинации.
/// </param>
/// <remarks>Включает флаг `isEnrolled` — записан ли текущий пользователь на лекцию.</remarks>
/// <response code="200">Список лекций (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<LectureDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll([FromQuery] LectureFilterRequest filter) =>
Ok(await _lectures.GetAllAsync(filter));
Ok(await _lectures.GetAllAsync(filter, CurrentUserId));
/// <summary>Получить детальную карточку лекции по ID.</summary>
/// <remarks>
/// Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию.
/// </remarks>
/// <param name="id">ID лекции.</param>
/// <response code="200">Детальные данные лекции.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Лекция не найдена.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Get(int id) =>
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
/// <summary>Создать новую лекцию.</summary>
/// <remarks>Только Admin. Курс задаётся при создании и не может быть изменён.</remarks>
/// <param name="req">Данные лекции: курс, преподаватель, локация, время, формат, вместимость.</param>
/// <response code="201">Лекция создана.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req));
/// <summary>Обновить лекцию по ID.</summary>
/// <remarks>Admin или Teacher. CourseId изменить нельзя.</remarks>
/// <param name="id">ID лекции.</param>
/// <param name="req">Обновляемые поля: преподаватель, локация, время, формат, описание.</param>
/// <response code="200">Обновлённые данные лекции.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
Ok(await _lectures.UpdateAsync(id, req));
Ok(await _lectures.UpdateAsync(id, req, CurrentUserId, CurrentUserIsAdmin));
/// <summary>Удалить лекцию по ID.</summary>
/// <remarks>Только Admin. Каскадно удаляет записи и отзывы.</remarks>
/// <param name="id">ID лекции.</param>
/// <response code="204">Лекция удалена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _lectures.DeleteAsync(id);
return NoContent();
}
/// <summary>Записаться на лекцию.</summary>
/// <remarks>
/// Только Student. Проверяет наличие свободных мест и отсутствие повторной записи.
/// После посещения начисляются монеты через gamification.
/// </remarks>
/// <param name="id">ID лекции.</param>
/// <response code="204">Запись выполнена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Student.</response>
/// <response code="404">Лекция не найдена.</response>
/// <response code="409">Студент уже записан или мест нет.</response>
[Authorize(Roles = "Student")]
[HttpPost("{id:int}/enroll")]
public async Task<IActionResult> Enroll(int id) { await _lectures.EnrollAsync(id, CurrentUserId); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Enroll(int id)
{
await _lectures.EnrollAsync(id, CurrentUserId);
return NoContent();
}
/// <summary>Отменить запись на лекцию.</summary>
/// <remarks>Только Student. Отменить можно только свою запись.</remarks>
/// <param name="id">ID лекции.</param>
/// <response code="204">Запись отменена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Student.</response>
/// <response code="404">Лекция или запись не найдена.</response>
[Authorize(Roles = "Student")]
[HttpDelete("{id:int}/enroll")]
public async Task<IActionResult> Unenroll(int id) { await _lectures.UnenrollAsync(id, CurrentUserId); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Unenroll(int id)
{
await _lectures.UnenrollAsync(id, CurrentUserId);
return NoContent();
}
/// <summary>Отметить посещение студента на лекции.</summary>
/// <remarks>
/// Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение
/// через gamification service.
/// </remarks>
/// <param name="id">ID лекции.</param>
/// <param name="userId">ID студента.</param>
/// <param name="attended">true — посетил, false — не посетил.</param>
/// <response code="204">Посещение отмечено.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция или запись студента не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpPatch("{id:int}/attendance/{userId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
{ await _lectures.MarkAttendanceAsync(id, userId, attended); return NoContent(); }
{
await _lectures.MarkAttendanceAsync(id, userId, attended, CurrentUserId, CurrentUserIsAdmin);
return NoContent();
}
/// <summary>Получить список записавшихся студентов на лекцию.</summary>
/// <remarks>Только Admin или Teacher. Включает флаг посещения (`attended`).</remarks>
/// <param name="id">ID лекции.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}/enrollments")]
[ProducesResponseType(typeof(PagedResult<EnrollmentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
Ok(await _lectures.GetEnrollmentsAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
/// <summary>Получить отзывы к лекции.</summary>
/// <remarks>Только Admin или Teacher.</remarks>
/// <param name="id">ID лекции.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByLectureAsync(id, pagination));
Ok(await _reviews.GetByLectureAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
}
@@ -5,31 +5,85 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Управление локациями проведения лекций (аудитории, онлайн-площадки).</summary>
[ApiController]
[Route("api/v1/locations")]
[Authorize]
[Produces("application/json")]
public class LocationsController : ControllerBase
{
private readonly ILocationService _locations;
public LocationsController(ILocationService locations) => _locations = locations;
/// <summary>Получить список всех локаций.</summary>
/// <response code="200">Список локаций.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(List<LocationDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll() => Ok(await _locations.GetAllAsync());
/// <summary>Получить локацию по ID.</summary>
/// <param name="id">ID локации.</param>
/// <response code="200">Данные локации.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Локация не найдена.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LocationDto>> Get(int id) => Ok(await _locations.GetByIdAsync(id));
/// <summary>Создать новую локацию.</summary>
/// <remarks>Только Admin. Локации также создаются автоматически при синхронизации с Modeus.</remarks>
/// <param name="req">Название, корпус, аудитория и/или адрес.</param>
/// <response code="201">Локация создана.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req));
/// <summary>Обновить локацию по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID локации.</param>
/// <param name="req">Обновляемые поля: название, корпус, аудитория, адрес.</param>
/// <response code="200">Обновлённые данные локации.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Локация не найдена.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LocationDto>> Update(int id, [FromBody] UpdateLocationRequest req) =>
Ok(await _locations.UpdateAsync(id, req));
/// <summary>Удалить локацию по ID.</summary>
/// <remarks>
/// Только Admin. При удалении локации у связанных лекций поле `locationId` становится null.
/// </remarks>
/// <param name="id">ID локации.</param>
/// <response code="204">Локация удалена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Локация не найдена.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _locations.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Отправка и планирование уведомлений через доступные каналы.</summary>
[ApiController]
[Route("api/v1/notifications")]
[Authorize]
[Produces("application/json")]
public class NotificationsController : ControllerBase
{
private readonly INotificationService _notifications;
public NotificationsController(INotificationService notifications)
{
_notifications = notifications;
}
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
/// <summary>Получить уведомления текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <param name="cancellationToken">Токен отмены запроса.</param>
/// <response code="200">Список уведомлений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<UserNotificationDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<PagedResult<UserNotificationDto>>> GetMine(
[FromQuery] PaginationRequest pagination,
CancellationToken cancellationToken) =>
Ok(await _notifications.GetUserNotificationsAsync(CurrentUserId, pagination, cancellationToken));
/// <summary>Отметить все уведомления текущего пользователя как прочитанные.</summary>
/// <param name="cancellationToken">Токен отмены запроса.</param>
/// <response code="204">Уведомления отмечены прочитанными.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpPatch("read-all")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> MarkAllRead(CancellationToken cancellationToken)
{
await _notifications.MarkAllReadAsync(CurrentUserId, cancellationToken);
return NoContent();
}
/// <summary>Отправить уведомление немедленно.</summary>
/// <remarks>
/// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`.
/// </remarks>
/// <param name="request">Канал, получатель, тема и текст уведомления.</param>
/// <response code="202">Уведомление принято к отправке.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost("send")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Send([FromBody] SendNotificationRequest request, CancellationToken cancellationToken)
{
var message = new NotificationMessage(
request.Channel,
request.Recipient,
request.Subject,
request.Body,
request.RecipientName,
request.Metadata);
await _notifications.SendAsync(message, cancellationToken);
return Accepted();
}
/// <summary>Запланировать отложенную отправку уведомления через Quartz.NET.</summary>
/// <param name="request">Уведомление и момент отправки.</param>
/// <response code="202">Уведомление поставлено в очередь Quartz.NET.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost("schedule")]
[ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ScheduledNotificationResponse>> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken)
{
var response = await _notifications.ScheduleAsync(request, cancellationToken);
return Accepted(response);
}
}
@@ -7,40 +7,161 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Отзывы студентов на лекции с LLM-анализом и модерацией.</summary>
[ApiController]
[Route("api/v1/reviews")]
[Authorize]
[Produces("application/json")]
public class ReviewsController : ControllerBase
{
private readonly IReviewService _reviews;
public ReviewsController(IReviewService reviews) => _reviews = reviews;
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
private readonly IReviewPromptService _reviewPrompts;
public ReviewsController(IReviewService reviews, IReviewPromptService reviewPrompts)
{
_reviews = reviews;
_reviewPrompts = reviewPrompts;
}
private int CurrentUserId => int.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
/// <summary>Создать отзыв к лекции.</summary>
/// <remarks>
/// Только Student. После создания отзыв отправляется на LLM-анализ
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
/// скрытно от пользователя.
/// </remarks>
/// <param name="req">ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.</param>
/// <response code="201">Отзыв создан и поставлен в очередь на LLM-анализ.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Student.</response>
/// <response code="404">Лекция не найдена.</response>
/// <response code="409">Студент уже оставил отзыв к этой лекции.</response>
[Authorize(Roles = "Student")]
[HttpPost]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
/// <summary>Получить список всех отзывов.</summary>
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
/// <param name="filter">Параметры фильтрации и пагинации.</param>
/// <response code="200">Список всех отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet]
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> List([FromQuery] ReviewFilterRequest filter) =>
Ok(await _reviews.GetAllAsync(filter));
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
/// <response code="200">Текущий шаблон промпта.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> GetLlmPrompt() =>
Ok(await _reviewPrompts.GetAsync());
/// <summary>Обновить промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Промпт применяется к следующим анализам и ручным повторам.</remarks>
/// <param name="request">Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}.</param>
/// <response code="200">Сохранённый шаблон промпта.</response>
/// <response code="400">Промпт пустой или не содержит обязательные плейсхолдеры.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPut("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> UpdateLlmPrompt([FromBody] UpdateReviewPromptRequest request) =>
Ok(await _reviewPrompts.UpdateAsync(request));
/// <summary>Получить отзыв по ID.</summary>
/// <remarks>Только Admin или Teacher.</remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="200">Данные отзыва (включая LLM-статус и сентимент).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Отзыв не найден.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ReviewDto>> Get(int id) => Ok(await _reviews.GetByIdAsync(id));
/// <summary>Обновить отзыв.</summary>
/// <remarks>
/// Разрешено любому авторизованному пользователю, но сервис проверяет владельца.
/// Изменение текста сбрасывает LLM-статус в `Pending` (повторный анализ).
/// </remarks>
/// <param name="id">ID отзыва.</param>
/// <param name="req">Новая оценка и/или текст.</param>
/// <response code="200">Обновлённые данные отзыва.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Отзыв принадлежит другому пользователю.</response>
/// <response code="404">Отзыв не найден.</response>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ReviewDto>> Update(int id, [FromBody] UpdateReviewRequest req) =>
Ok(await _reviews.UpdateAsync(id, CurrentUserId, req));
/// <summary>Удалить отзыв.</summary>
/// <remarks>Владелец может удалить свой отзыв. Admin может удалить любой.</remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="204">Отзыв удалён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Нет прав на удаление (не владелец и не Admin).</response>
/// <response code="404">Отзыв не найден.</response>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
return NoContent();
}
[Authorize(Roles = "Admin")]
[HttpGet("pending")]
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetPendingAsync(pagination));
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
/// <remarks>
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его
/// на повторную обработку.
/// </remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="204">Повторный анализ запланирован.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Отзыв не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPost("{id:int}/reanalyze")]
public async Task<IActionResult> Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Reanalyze(int id)
{
await _reviews.ReanalyzeAsync(id);
return NoContent();
}
}
@@ -5,28 +5,75 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Синхронизация данных из внешней системы расписания Modeus (только Admin).</summary>
[ApiController]
[Route("api/v1/sync")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
public class SyncController : ControllerBase
{
private readonly IScheduleSyncService _sync;
public SyncController(IScheduleSyncService sync) => _sync = sync;
/// <summary>Запустить синхронизацию расписания лекций из Modeus.</summary>
/// <remarks>
/// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных
/// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду,
/// размеру выборки, аудиториям, участникам, реализациям курсов/циклов,
/// специальностям, годам набора, профилям, учебным планам и типам занятий.
/// </remarks>
/// <param name="req">Параметры поиска событий во внешнем сервисе расписания.</param>
/// <response code="200">Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("schedule")]
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
Ok(await _sync.SyncScheduleAsync(req));
/// <summary>Получить статус последней синхронизации.</summary>
/// <remarks>Только Admin. Возвращает время и результат последней успешной синхронизации.</remarks>
/// <response code="200">Статус синхронизации.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpGet("status")]
[ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<SyncStatusDto>> Status() =>
Ok(await _sync.GetLastSyncStatusAsync());
/// <summary>Синхронизировать аудитории (локации) из Modeus.</summary>
/// <remarks>
/// Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт
/// соответствующие записи в таблице locations.
/// </remarks>
/// <response code="200">Результат синхронизации аудиторий.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("rooms")]
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
Ok(await _sync.SyncRoomsAsync());
/// <summary>Поиск преподавателей в Modeus по ФИО.</summary>
/// <remarks>
/// Только Admin. Ищет преподавателей через внешнее API и возвращает список
/// для ручного импорта. Найденные преподаватели не создаются автоматически.
/// </remarks>
/// <param name="fullname">Полное имя или часть имени преподавателя для поиска.</param>
/// <response code="200">Список найденных преподавателей.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("employees")]
[ProducesResponseType(typeof(List<EmployeeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
Ok(await _sync.SearchEmployeesAsync(fullname));
}
@@ -6,35 +6,101 @@ using UniVerse.Domain.Enums;
namespace UniVerse.Api.Controllers;
/// <summary>Управление тегами для категоризации курсов (институты, факультеты, темы и др.).</summary>
[ApiController]
[Route("api/v1/tags")]
[Authorize]
[Produces("application/json")]
public class TagsController : ControllerBase
{
private readonly ITagService _tags;
public TagsController(ITagService tags) => _tags = tags;
/// <summary>Получить список тегов с опциональной фильтрацией по типу и родителю.</summary>
/// <param name="type">Тип тега: Institute, Faculty, Subject, Organization, Topic, Other.</param>
/// <param name="parentId">ID родительского тега (фильтрация дочерних).</param>
/// <response code="200">Список тегов.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(List<TagDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
Ok(await _tags.GetAllAsync(type, parentId));
/// <summary>Получить тег по ID.</summary>
/// <param name="id">ID тега.</param>
/// <response code="200">Данные тега.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Тег не найден.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TagDto>> Get(int id) => Ok(await _tags.GetByIdAsync(id));
/// <summary>Получить иерархическое дерево всех тегов.</summary>
/// <remarks>
/// Возвращает корневые теги с вложенными дочерними тегами.
/// Полезно для построения фильтрующих UI-компонентов.
/// </remarks>
/// <response code="200">Иерархический список тегов.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("tree")]
[ProducesResponseType(typeof(List<TagTreeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetTree() => Ok(await _tags.GetTreeAsync());
/// <summary>Создать новый тег.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="req">Название, тип и опциональный родительский тег.</param>
/// <response code="201">Тег создан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req));
/// <summary>Обновить тег по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID тега.</param>
/// <param name="req">Новое название, тип и/или родительский тег.</param>
/// <response code="200">Обновлённые данные тега.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Тег не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TagDto>> Update(int id, [FromBody] UpdateTagRequest req) =>
Ok(await _tags.UpdateAsync(id, req));
/// <summary>Удалить тег по ID.</summary>
/// <remarks>
/// Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`).
/// Дочерние теги остаются, но их `parentId` становится null.
/// </remarks>
/// <param name="id">ID тега.</param>
/// <response code="204">Тег удалён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Тег не найден.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); }
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _tags.DeleteAsync(id);
return NoContent();
}
}
@@ -8,71 +8,269 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Управление пользователями, профилями и геймификацией.</summary>
[ApiController]
[Route("api/v1/users")]
[Authorize]
[Produces("application/json")]
public class UsersController : ControllerBase
{
private readonly IUserService _users;
private readonly IReviewService _reviews;
private readonly IGamificationService _gamification;
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
{
_users = users; _reviews = reviews; _gamification = gamification;
}
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
private static CurrentUserDto ToCurrentUserDto(UserDto user) => new(
user.Id,
user.Email,
user.DisplayName,
user.AvatarUrl,
user.Roles,
user.Xp,
user.Coins,
user.Level,
user.CreatedAt);
/// <summary>Получить профиль текущего пользователя.</summary>
/// <response code="200">Данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me")]
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CurrentUserDto>> GetMe() =>
Ok(ToCurrentUserDto(await _users.GetByIdAsync(CurrentUserId)));
/// <summary>Обновить профиль текущего пользователя (displayName, avatarUrl).</summary>
/// <param name="req">Обновляемые поля профиля.</param>
/// <response code="200">Обновлённые данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpPut("me")]
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CurrentUserDto>> UpdateMe([FromBody] UpdateUserRequest req) =>
Ok(ToCurrentUserDto(await _users.UpdateProfileAsync(CurrentUserId, req)));
/// <summary>Получить статистику текущего пользователя.</summary>
/// <response code="200">Статистика текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me/stats")]
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserStatsDto>> MyStats() =>
Ok(await _users.GetStatsAsync(CurrentUserId));
/// <summary>Получить список записей текущего пользователя на лекции.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me/enrollments")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
/// <summary>Получить отзывы текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyReviews([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByUserAsync(CurrentUserId, pagination));
/// <summary>Получить достижения текущего пользователя.</summary>
/// <response code="200">Список полученных достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/achievements")]
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyAchievements() =>
Ok(await _gamification.GetUserAchievementsAsync(CurrentUserId));
/// <summary>Получить историю транзакций монет текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">История транзакций (пагинированная).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/transactions")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyTransactions([FromQuery] PaginationRequest pagination) =>
Ok(await _gamification.GetTransactionsAsync(CurrentUserId, pagination));
/// <summary>Получить профиль пользователя по ID.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Данные пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserDto>> Get(int id) => Ok(await _users.GetByIdAsync(id));
/// <summary>Обновить профиль пользователя (displayName, avatarUrl).</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте PUT /api/v1/users/me.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="req">Обновляемые поля профиля.</param>
/// <response code="200">Обновлённые данные пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
return Ok(await _users.UpdateProfileAsync(id, req));
}
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req) =>
Ok(await _users.UpdateProfileAsync(id, req));
/// <summary>Получить статистику пользователя (XP, монеты, уровень, посещения).</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Статистика пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/stats")]
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
/// <summary>Получить список записей пользователя на лекции.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/enrollments")]
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
// Delegate to lecture service would be more proper, but returning reviews for now
return Ok();
}
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _users.GetEnrollmentsAsync(id, pagination));
/// <summary>Получить отзывы пользователя.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByUserAsync(id, pagination));
/// <summary>Получить достижения пользователя.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Список полученных достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/achievements")]
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Achievements(int id) =>
Ok(await _gamification.GetUserAchievementsAsync(id));
/// <summary>Получить историю транзакций монет пользователя.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/transactions.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">История транзакций (пагинированная).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/transactions")]
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
}
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _gamification.GetTransactionsAsync(id, pagination));
/// <summary>Получить список всех пользователей с фильтрацией и пагинацией.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="filter">Параметры фильтрации (поиск, роль, активность) и пагинации.</param>
/// <response code="200">Список пользователей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet]
[ProducesResponseType(typeof(PagedResult<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
Ok(await _users.GetAllAsync(filter));
/// <summary>Изменить набор ролей пользователя.</summary>
/// <remarks>Только Admin. Доступные роли: Student, Teacher, Admin.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="roles">Новый набор ролей пользователя.</param>
/// <response code="204">Роли успешно изменены.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPatch("{id:int}/role")]
public async Task<IActionResult> SetRole(int id, [FromBody] UserRole role)
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetRole(int id, [FromBody] IReadOnlyCollection<UserRole> roles)
{
await _users.SetRoleAsync(id, role);
if (roles.Count == 0)
return BadRequest("At least one role is required.");
await _users.SetRolesAsync(id, roles);
return NoContent();
}
/// <summary>Активировать или деактивировать аккаунт пользователя.</summary>
/// <remarks>Только Admin. Деактивированный пользователь не может войти в систему.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="isActive">true — активировать, false — деактивировать.</param>
/// <response code="204">Статус успешно изменён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPatch("{id:int}/active")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
{
await _users.SetActiveAsync(id, isActive);
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace UniVerse.Api.Filters;
/// <summary>
/// Swagger operation filter that:
/// 1. Adds Bearer security requirement only to endpoints that actually require authentication.
/// 2. Appends a "Required roles: ..." remark to the operation description when role restrictions exist.
///
/// This replaces the global AddSecurityRequirement approach so anonymous endpoints
/// (auth/login, auth/refresh, auth/callback) don't show the lock icon in Swagger UI.
/// </summary>
public class AuthorizeOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Collect [Authorize] and [AllowAnonymous] from both the controller and the action.
var actionAttributes = context.MethodInfo.GetCustomAttributes(inherit: true);
var controllerAttributes = context.MethodInfo.DeclaringType?
.GetCustomAttributes(inherit: true) ?? [];
var allAttributes = actionAttributes.Concat(controllerAttributes).ToList();
var hasAllowAnonymous = allAttributes.OfType<AllowAnonymousAttribute>().Any();
if (hasAllowAnonymous)
return; // completely public — no lock icon
var authorizeAttributes = allAttributes.OfType<AuthorizeAttribute>().ToList();
if (authorizeAttributes.Count == 0)
return; // no [Authorize] at all — also public
// Collect all distinct roles across all [Authorize(Roles = "...")] attributes.
var roles = authorizeAttributes
.Where(a => !string.IsNullOrWhiteSpace(a.Roles))
.SelectMany(a => a.Roles!.Split(',', StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(r => r)
.ToList();
// Append role information to the operation description.
var roleInfo = roles.Count > 0
? $"**Required roles:** {string.Join(", ", roles)}"
: "**Required:** any authenticated user";
operation.Description = string.IsNullOrWhiteSpace(operation.Description)
? roleInfo
: $"{operation.Description}\n\n{roleInfo}";
operation.Responses ??= new OpenApiResponses();
// Add 401 / 403 responses if not already declared.
if (!operation.Responses.ContainsKey("401"))
{
operation.Responses.Add("401", new OpenApiResponse
{
Description = "Unauthorized — JWT token missing or invalid"
});
}
if (roles.Count > 0 && !operation.Responses.ContainsKey("403"))
{
operation.Responses.Add("403", new OpenApiResponse
{
Description = $"Forbidden — requires role: {string.Join(" or ", roles)}"
});
}
// Add Bearer security requirement to this specific operation.
// OpenAPI v2 (Microsoft.OpenApi 2.x) uses OpenApiSecuritySchemeReference
// instead of OpenApiSecurityScheme with a Reference property.
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", context.Document);
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
[bearerSchemeRef] = []
});
}
}
@@ -24,6 +24,7 @@ public class ExceptionHandlingMiddleware
{
var (statusCode, title) = exception switch
{
BadRequestException => ((int)HttpStatusCode.BadRequest, "Bad Request"),
NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"),
ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"),
ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"),
@@ -0,0 +1,8 @@
namespace UniVerse.Api.Options;
public class ReviewAnalysisOptions
{
public const string SectionName = "Llm:ReviewAnalysis";
public int MaxConcurrentProcessing { get; set; } = 1;
}
+89 -15
View File
@@ -4,16 +4,29 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Quartz;
using Serilog;
using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Filters;
using UniVerse.Api.Middleware;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Infrastructure.Services;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.ExternalServices;
using UniVerse.Infrastructure.Notifications;
var builder = WebApplication.CreateBuilder(args);
var useAspire = builder.Configuration.GetValue<bool>("Aspire:Enabled");
var isOpenApiGeneration = AppDomain.CurrentDomain.GetAssemblies()
.Any(assembly => assembly.GetName().Name == "GetDocument.Insider");
if (useAspire)
{
builder.AddServiceDefaults();
}
// --- Serilog ---
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
@@ -29,7 +42,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
npgsql =>
{
npgsql.EnableRetryOnFailure(3);
npgsql.MigrationsAssembly("UniVerse.Infrastructure");
npgsql.MigrationsAssembly("UniVerse.Infrastructure"); // Указывает EF Core, в какой сборке искать/хранить миграции.
});
});
@@ -50,7 +63,7 @@ builder.Services.AddAuthentication(options =>
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"] ?? "default-dev-secret-key-change-in-production-32chars!!"))
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
};
});
builder.Services.AddAuthorization();
@@ -77,10 +90,40 @@ builder.Services.AddScoped<ILocationService, LocationService>();
builder.Services.AddScoped<ICourseService, CourseService>();
builder.Services.AddScoped<ILectureService, LectureService>();
builder.Services.AddScoped<IReviewService, ReviewService>();
builder.Services.AddScoped<IReviewPromptService, ReviewPromptService>();
builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IAchievementService, AchievementService>();
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
builder.Services.AddScoped<IMicrosoftAuthClient, MicrosoftAuthClient>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
builder.Services.AddSingleton<ReviewAnalysisQueue>();
builder.Services.AddSingleton<IReviewAnalysisQueue>(sp => sp.GetRequiredService<ReviewAnalysisQueue>());
builder.Services.AddTransient<NotificationJob>();
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
builder.Services.AddOptions<ReviewAnalysisOptions>()
.Bind(builder.Configuration.GetSection(ReviewAnalysisOptions.SectionName))
.Validate(options => options.MaxConcurrentProcessing >= 1,
"Llm:ReviewAnalysis:MaxConcurrentProcessing must be greater than or equal to 1.")
.ValidateOnStart();
builder.Services.AddQuartz();
if (!isOpenApiGeneration)
{
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
}
if (builder.Environment.IsDevelopment() && !isOpenApiGeneration)
{
builder.Services.AddQuartzDashboard(options =>
{
options.ReadOnly = true;
});
}
// --- HTTP Clients ---
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
@@ -96,7 +139,11 @@ builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
});
// --- Background Services ---
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
if (!isOpenApiGeneration)
{
builder.Services.AddHostedService<ReviewAnalysisWorker>();
builder.Services.AddHostedService<AchievementCatalogHostedService>();
}
// --- Controllers ---
builder.Services.AddControllers()
@@ -114,9 +161,16 @@ builder.Services.AddSwaggerGen(options =>
{
Title = "UniVerse API",
Version = "v1",
Description = "University schedule, reviews, and gamification platform"
Description =
"REST API веб-платформы UniVerse.\n\n" +
"Аутентификация: JWT Bearer (получить через `POST /api/v1/auth/login/microsoft` или `POST /api/v1/auth/login/dev` в Development).",
Contact = new OpenApiContact
{
Name = "UniVerse Dev"
}
});
// Bearer security scheme definition (used per-endpoint by AuthorizeOperationFilter)
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
@@ -124,34 +178,54 @@ builder.Services.AddSwaggerGen(options =>
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token"
Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`"
});
options.AddSecurityRequirement(doc =>
{
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
return new OpenApiSecurityRequirement
{
[bearerSchemeRef] = new List<string>()
};
});
// Include XML doc comments generated from controller /// summaries
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
options.IncludeXmlComments(xmlPath);
// Per-endpoint security requirement + role documentation (replaces global AddSecurityRequirement)
options.OperationFilter<AuthorizeOperationFilter>();
});
var app = builder.Build();
if (useAspire)
{
app.MapDefaultEndpoints();
}
// --- Middleware Pipeline ---
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "UniVerse API v1"));
app.UseStaticFiles();
app.UseSwagger(c =>
{
c.RouteTemplate = "api/docs/{documentName}/swagger.json";
});
app.UseSwaggerUI(c =>
{
c.RoutePrefix = "api/docs";
c.SwaggerEndpoint("v1/swagger.json", "UniVerse API v1");
});
}
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.UseAntiforgery();
app.MapQuartzDashboard();
}
app.MapControllers();
app.Run();
@@ -4,7 +4,8 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchBrowser": true,
"launchUrl": "api/docs",
"applicationUrl": "http://localhost:5019",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
+24 -5
View File
@@ -7,26 +7,35 @@
<RootNamespace>UniVerse.Api</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
<OpenApiDocumentsDirectory>$(BaseIntermediateOutputPath)openapi</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<!-- Suppress warnings for public members without XML docs -->
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Quartz.Dashboard" Version="3.18.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\UniVerse.Application\UniVerse.Application.csproj" />
<ProjectReference Include="..\UniVerse.Infrastructure\UniVerse.Infrastructure.csproj" />
<ProjectReference Include="..\UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
@@ -35,4 +44,14 @@
</Content>
</ItemGroup>
<Target
Name="CopyGeneratedOpenApiDocument"
AfterTargets="Build"
Condition="Exists('$(OpenApiDocumentsDirectory)/openapi.json')">
<Copy
SourceFiles="$(OpenApiDocumentsDirectory)/openapi.json"
DestinationFiles="$(MSBuildProjectDirectory)/openapi.json"
SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -4,5 +4,23 @@
"Default": "Information",
"Microsoft.AspNetCore": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=db;Port=5444;Database=universe;Username=universe;Password=pass"
},
"Jwt": {
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
"Issuer": "UniVerse",
"Audience": "UniVerse",
"AccessTokenExpirationMinutes": "30",
"RefreshTokenExpirationDays": "30"
},
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "sfedu.ru",
"ClientId": "",
"ClientSecret": "",
"Domain": "sfedu.onmicrosoft.com",
"CallbackPath": "/signin-oidc"
}
}
+17 -16
View File
@@ -7,16 +7,6 @@
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=universe;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
"Issuer": "UniVerse",
"Audience": "UniVerse",
"AccessTokenExpirationMinutes": "30",
"RefreshTokenExpirationDays": "30"
},
"Cors": {
"Origins": [
"http://localhost:5173",
@@ -26,22 +16,33 @@
"Llm": {
"BaseUrl": "https://api.openai.com/v1/",
"ApiKey": "",
"Model": "gpt-4o-mini"
"Model": "gpt-4o-mini",
"ReviewAnalysis": {
"MaxConcurrentProcessing": 1
}
},
"ModeusApi": {
"BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": ""
},
"Gamification": {
"XpThresholds": [0, 100, 300, 600, 1000, 1500, 2500, 4000]
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
"Microsoft": "Information",
"System": "Information"
}
}
},
"Email": {
"Smtp": {
"Host": "",
"Port": 587,
"EnableSsl": true,
"UserName": "",
"Password": "",
"FromAddress": "no-reply@universe.local",
"FromName": "UniVerse"
}
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
var builder = DistributedApplication.CreateBuilder(args);
var api = builder
.AddProject<Projects.UniVerse_Api>("universe-api")
.WithEnvironment("Aspire__Enabled", "true");
// Запуск фронтенда (Vue + Vite) в dev-режиме вместе.
// Требования: установлен pnpm (или включён corepack), зависимости фронта установлены.
builder
.AddExecutable("universe-frontend", "pnpm", workingDirectory: "../../frontend")
.WithArgs("run", "dev:aspire")
.WithHttpEndpoint(targetPort: 5173, port: 5173, name: "http", isProxied: false)
// Используется в vite.config.ts для server.proxy['/api'].target
.WithEnvironment("VITE_API_PROXY_TARGET", api.GetEndpoint("http"));
builder.Build().Run();
@@ -0,0 +1,48 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://127.0.0.1:17156;http://127.0.0.1:15060",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"ALL_PROXY": "",
"http_proxy": "",
"https_proxy": "",
"all_proxy": "",
"NO_PROXY": "localhost,127.0.0.1,::1",
"no_proxy": "localhost,127.0.0.1,::1",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://127.0.0.1:21010",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://127.0.0.1:23046",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://127.0.0.1:22274"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://127.0.0.1:15060",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"ALL_PROXY": "",
"http_proxy": "",
"https_proxy": "",
"all_proxy": "",
"NO_PROXY": "localhost,127.0.0.1,::1",
"no_proxy": "localhost,127.0.0.1,::1",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://127.0.0.1:19138",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://127.0.0.1:18238",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://127.0.0.1:20274"
}
}
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>fb90d29a-6c48-471b-b19f-d2f431a5ef38</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -0,0 +1,5 @@
{
"appHost": {
"path": "UniVerse.AppHost.csproj"
}
}
@@ -5,8 +5,8 @@ namespace UniVerse.Application.DTOs.Auth;
public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User);
public record AuthResult(AuthResponse Response, string RefreshToken);
public record UserAuthDto(int Id, string Email, string? DisplayName, UserRole Role);
public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList<UserRole> Roles);
public record LoginMicrosoftRequest(string AuthorizationCode);
public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null);
public record DevLoginRequest(string Email, string? DisplayName = null, UserRole Role = UserRole.Student);
public record DevLoginRequest(string Email, string? DisplayName = null, IReadOnlyList<UserRole>? Roles = null);
@@ -11,3 +11,8 @@ public record CoinTransactionDto(
string? Description,
DateTime CreatedAt
);
public record LevelProgressDto(
int CurrentLevelXp,
int? NextLevelXp
);
@@ -19,7 +19,8 @@ public record LectureDto(
int MaxEnrollments,
int EnrollmentsCount,
string? OnlineUrl,
DateTime CreatedAt
DateTime CreatedAt,
bool IsEnrolled = false
);
public record LectureDetailDto(
@@ -0,0 +1,42 @@
namespace UniVerse.Application.DTOs.Notifications;
public static class NotificationChannels
{
public const string Email = "email";
}
public record NotificationMessage(
string Channel,
string Recipient,
string Subject,
string Body,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record SendNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduleNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
DateTimeOffset SendAt,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt);
public record UserNotificationDto(
int Id,
string Type,
string Title,
string Body,
bool IsRead,
DateTime CreatedAt
);
@@ -15,9 +15,20 @@ public record ReviewDto(
double? QualityScore,
bool? IsInformative,
string[]? LlmTags,
string? LlmRawOutput,
DateTime CreatedAt
);
public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text);
public record UpdateReviewRequest(ReviewRating Rating, string? Text);
public record ReviewFilterRequest(
ReviewLlmStatus? LlmStatus,
int Page = 1,
int PageSize = 20
);
public record ReviewPromptDto(string Prompt, DateTime? UpdatedAt);
public record UpdateReviewPromptRequest(string Prompt);
@@ -1,13 +1,27 @@
namespace UniVerse.Application.DTOs.Sync;
public record SyncScheduleRequest(
string? SpecialtyCode,
IReadOnlyList<string>? SpecialtyCode,
DateTime? TimeMin,
DateTime? TimeMax,
string? TypeId
IReadOnlyList<string>? TypeId,
int? Size = null,
IReadOnlyList<string>? RoomId = null,
IReadOnlyList<string>? AttendeePersonId = null,
IReadOnlyList<string>? CourseUnitRealizationId = null,
IReadOnlyList<string>? CycleRealizationId = null,
IReadOnlyList<int>? LearningStartYear = null,
IReadOnlyList<string>? ProfileName = null,
IReadOnlyList<string>? CurriculumId = null
);
public record SyncResultDto(int Created, int Updated, int Skipped, string? Error);
public record SyncResultDto(
int Created,
int Updated,
int Skipped,
string? Error,
IReadOnlyList<string>? Details = null
);
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
@@ -7,7 +7,7 @@ public record UserDto(
string Email,
string? DisplayName,
string? AvatarUrl,
UserRole Role,
IReadOnlyList<UserRole> Roles,
bool IsActive,
int Xp,
int Coins,
@@ -15,6 +15,18 @@ public record UserDto(
DateTime CreatedAt
);
public record CurrentUserDto(
int Id,
string Email,
string? DisplayName,
string? AvatarUrl,
IReadOnlyList<UserRole> Roles,
int Xp,
int Coins,
int Level,
DateTime CreatedAt
);
public record UserStatsDto(
int TotalLectures,
int AttendedLectures,
@@ -22,9 +34,16 @@ public record UserStatsDto(
int Xp,
int Coins,
int Level,
int AchievementsCount
int AchievementsCount,
int CurrentLevelXp,
int? NextLevelXp,
int ActiveEnrollments,
int EnrollmentSlotLimit,
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
);
public record EnrollmentSlotRuleDto(int Level, int Slots);
public record UpdateUserRequest(
string? DisplayName,
string? AvatarUrl
@@ -5,9 +5,9 @@ namespace UniVerse.Application.Interfaces;
public interface IAuthService
{
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode);
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null);
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(string refreshToken);
Task<UserDto> GetCurrentUserAsync(int userId);
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
}
@@ -10,7 +10,8 @@ public interface IGamificationService
Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
int? reviewId = null, int? achievementId = null, string? description = null);
Task CheckAndAwardAchievementsAsync(int userId);
int CalculateLevel(int xp);
Task<int> CalculateLevelAsync(int xp);
Task<LevelProgressDto> GetLevelProgressAsync(int xp);
Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId);
Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination);
}
@@ -5,13 +5,13 @@ namespace UniVerse.Application.Interfaces;
public interface ILectureService
{
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter);
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null);
Task<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null);
Task<LectureDto> CreateAsync(CreateLectureRequest request);
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request);
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request, int currentUserId, bool isAdmin = false);
Task DeleteAsync(int id);
Task EnrollAsync(int lectureId, int userId);
Task UnenrollAsync(int lectureId, int userId);
Task MarkAttendanceAsync(int lectureId, int userId, bool attended);
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination);
Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false);
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false);
}
@@ -3,5 +3,4 @@ namespace UniVerse.Application.Interfaces;
public interface ILlmAnalysisService
{
Task AnalyzeReviewAsync(int reviewId);
Task ProcessPendingReviewsAsync();
}
@@ -4,7 +4,8 @@ public record LlmReviewAnalysis(
double QualityScore,
string Sentiment,
string[] Tags,
bool IsInformative
bool IsInformative,
string RawOutput
);
public interface ILlmClient
@@ -0,0 +1,11 @@
namespace UniVerse.Application.Interfaces;
public interface IMicrosoftAuthClient
{
Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
string authorizationCode,
string redirectUri,
CancellationToken cancellationToken = default);
}
public record MicrosoftTokenResult(string IdToken);
@@ -0,0 +1,9 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationProvider
{
string Channel { get; }
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,14 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationScheduler
{
Task<ScheduledNotificationResponse> ScheduleAsync(
NotificationMessage message,
DateTimeOffset sendAt,
string? jobId = null,
CancellationToken cancellationToken = default);
Task CancelAsync(string jobId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,13 @@
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Common;
namespace UniVerse.Application.Interfaces;
public interface INotificationService
{
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default);
Task<UserNotificationDto> CreateUserNotificationAsync(int userId, string type, string title, string body, CancellationToken cancellationToken = default);
Task<PagedResult<UserNotificationDto>> GetUserNotificationsAsync(int userId, PaginationRequest pagination, CancellationToken cancellationToken = default);
Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,6 @@
namespace UniVerse.Application.Interfaces;
public interface IReviewAnalysisQueue
{
Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
using UniVerse.Application.DTOs.Reviews;
namespace UniVerse.Application.Interfaces;
public interface IReviewPromptService
{
Task<ReviewPromptDto> GetAsync();
Task<ReviewPromptDto> UpdateAsync(UpdateReviewPromptRequest request);
}
@@ -9,8 +9,8 @@ public interface IReviewService
Task<ReviewDto> GetByIdAsync(int id);
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
Task DeleteAsync(int id, int userId, bool isAdmin = false);
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination, int? currentUserId = null, bool isAdmin = false);
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
Task ReanalyzeAsync(int id);
}
@@ -1,4 +1,5 @@
using UniVerse.Application.DTOs.Sync;
using System.Text.Json.Serialization;
namespace UniVerse.Application.Interfaces;
@@ -15,11 +16,102 @@ public interface IModeusApiClient
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
Task<ModeusRoomsResponse> SearchRoomsAsync();
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default);
}
// Modeus API response models
public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId);
public record ModeusEventsResponse(List<ModeusEvent> Events);
public record ModeusRoom(string Id, string Name, string? Building);
public record ModeusRoomsResponse(List<ModeusRoom> Rooms);
public class ModeusEvent
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string? NameShort { get; init; }
public string? Description { get; init; }
public string? TypeId { get; init; }
public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; }
[JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; }
}
public class ModeusEventLinks
{
[JsonPropertyName("course-unit-realization")]
public ModeusHrefLink? CourseUnitRealization { get; init; }
}
public class ModeusEventsResponse
{
[JsonPropertyName("_embedded")]
public ModeusEventsEmbedded? Embedded { get; init; }
public List<ModeusEvent>? Events { get; init; }
public ModeusPage? Page { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusEvent> EventItems => Embedded?.Events ?? Events ?? [];
}
public class ModeusEventsEmbedded
{
public List<ModeusEvent>? Events { get; init; }
[JsonPropertyName("course-unit-realizations")]
public List<ModeusCourseUnitRealization>? CourseUnitRealizations { get; init; }
[JsonPropertyName("event-rooms")]
public List<ModeusEventRoom>? EventRooms { get; init; }
[JsonPropertyName("event-teams")]
public List<ModeusEventTeam>? EventTeams { get; init; }
[JsonPropertyName("event-attendees")]
public List<ModeusEventAttendee>? EventAttendees { get; init; }
public List<ModeusPerson>? Persons { get; init; }
public List<ModeusRoom>? Rooms { get; init; }
}
public record ModeusHrefLink(string? Href);
public record ModeusCourseUnitRealization(string Id, string Name, string? NameShort);
public class ModeusEventRoom
{
public string Id { get; init; } = string.Empty;
[JsonPropertyName("_links")]
public ModeusEventRoomLinks? Links { get; init; }
}
public class ModeusEventRoomLinks
{
public ModeusHrefLink? Event { get; init; }
public ModeusHrefLink? Room { get; init; }
}
public record ModeusEventTeam(string EventId, int? Size);
public class ModeusEventAttendee
{
public string Id { get; init; } = string.Empty;
public string? RoleId { get; init; }
public string? RoleName { get; init; }
[JsonPropertyName("_links")]
public ModeusEventAttendeeLinks? Links { get; init; }
}
public class ModeusEventAttendeeLinks
{
public ModeusHrefLink? Event { get; init; }
public ModeusHrefLink? Person { get; init; }
}
public record ModeusPerson(string Id, string? LastName, string? FirstName, string? MiddleName, string? FullName);
public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address);
public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity);
public record ModeusRoomsEmbedded(List<ModeusRoom>? Rooms);
public record ModeusPage(int Size, int TotalElements, int TotalPages, int Number);
public class ModeusRoomsResponse
{
[JsonPropertyName("_embedded")]
public ModeusRoomsEmbedded? Embedded { get; init; }
public ModeusPage? Page { get; init; }
public List<ModeusRoom>? Rooms { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusRoom> RoomItems => Embedded?.Rooms ?? Rooms ?? [];
}
public record ModeusEmployee(string? Id, string FullName, string? Department);
@@ -1,4 +1,5 @@
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Users;
using UniVerse.Domain.Enums;
@@ -9,7 +10,8 @@ public interface IUserService
Task<UserDto> GetByIdAsync(int id);
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
Task<UserStatsDto> GetStatsAsync(int id);
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
Task SetRoleAsync(int id, UserRole role);
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
Task SetActiveAsync(int id, bool isActive);
}
@@ -16,11 +16,16 @@ public static class MappingExtensions
// --- User ---
public static UserDto ToDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
user.Role, user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
);
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
);
public static UserAuthDto ToAuthDto(this User user) => new(
user.Id, user.Email, user.DisplayName, user.Role
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
);
// --- Tag ---
@@ -46,14 +51,14 @@ public static class MappingExtensions
);
// --- Lecture ---
public static LectureDto ToDto(this Lecture lecture) => new(
public static LectureDto ToDto(this Lecture lecture, bool isEnrolled = false) => new(
lecture.Id, lecture.CourseId, lecture.Course?.Name ?? "",
lecture.TeacherId, lecture.Teacher?.DisplayName,
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new(
@@ -79,7 +84,7 @@ public static class MappingExtensions
review.UserId, review.User?.DisplayName,
review.Rating, review.Text, review.LlmStatus,
review.Sentiment, review.QualityScore, review.IsInformative,
review.LlmTags, review.CreatedAt
review.LlmTags, review.LlmRawOutput, review.CreatedAt
);
// --- Achievement ---
@@ -0,0 +1,27 @@
namespace UniVerse.Application.Prompts;
public static class ReviewPromptTemplate
{
public const string LectureContextPlaceholder = "{lectureContext}";
public const string ReviewTextPlaceholder = "{reviewText}";
public const string Default = """
Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
- quality_score: число от 0 до 1, указывающее на качество отзыва;
- sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
- tags: массив соответствующих тематических тегов;
- is_informative: логическое значение, указывающее, является ли отзыв информативным.
Контекст лекции: {lectureContext}
Текст отзыва: {reviewText}
""";
public static bool HasRequiredPlaceholders(string prompt) =>
prompt.Contains(LectureContextPlaceholder, StringComparison.Ordinal) &&
prompt.Contains(ReviewTextPlaceholder, StringComparison.Ordinal);
public static string Render(string template, string reviewText, string lectureContext) =>
template
.Replace(LectureContextPlaceholder, lectureContext)
.Replace(ReviewTextPlaceholder, reviewText);
}
@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,7 @@
namespace UniVerse.Domain.Entities;
public class LevelThreshold
{
public int Level { get; set; }
public int RequiredXp { get; set; }
}
@@ -14,6 +14,7 @@ public class Review
public double? QualityScore { get; set; }
public bool? IsInformative { get; set; }
public string[]? LlmTags { get; set; }
public string? LlmRawOutput { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,11 @@
namespace UniVerse.Domain.Entities;
public class ReviewPromptSetting
{
public const int SingletonId = 1;
public int Id { get; set; } = SingletonId;
public string Prompt { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+2 -1
View File
@@ -8,7 +8,6 @@ public class User
public string Email { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? AvatarUrl { get; set; }
public UserRole Role { get; set; } = UserRole.Student;
public bool IsActive { get; set; } = true;
public string? MicrosoftId { get; set; }
public int Xp { get; set; }
@@ -19,9 +18,11 @@ public class User
// Navigation properties
public StudentProfile? StudentProfile { get; set; }
public TeacherProfile? TeacherProfile { get; set; }
public ICollection<UserRoleAssignment> Roles { get; set; } = new List<UserRoleAssignment>();
public ICollection<LectureEnrollment> Enrollments { get; set; } = new List<LectureEnrollment>();
public ICollection<Review> Reviews { get; set; } = new List<Review>();
public ICollection<UserAchievement> UserAchievements { get; set; } = new List<UserAchievement>();
public ICollection<CoinTransaction> CoinTransactions { get; set; } = new List<CoinTransaction>();
public ICollection<UserNotification> Notifications { get; set; } = new List<UserNotification>();
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
@@ -0,0 +1,14 @@
namespace UniVerse.Domain.Entities;
public class UserNotification
{
public int Id { get; set; }
public int UserId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}
@@ -0,0 +1,11 @@
using UniVerse.Domain.Enums;
namespace UniVerse.Domain.Entities;
public class UserRoleAssignment
{
public int UserId { get; set; }
public UserRole Role { get; set; }
public User User { get; set; } = null!;
}
@@ -0,0 +1,8 @@
namespace UniVerse.Domain.Exceptions;
public class BadRequestException : Exception
{
public BadRequestException(string message) : base(message)
{
}
}
@@ -0,0 +1,21 @@
namespace UniVerse.Domain.Services;
public static class EnrollmentSlotPolicy
{
private static readonly IReadOnlyList<EnrollmentSlotRule> SlotRules =
[
new(1, 3),
new(3, 5),
new(4, 7)
];
public static IReadOnlyList<EnrollmentSlotRule> Rules => SlotRules;
public static int GetLimitForLevel(int level) =>
SlotRules
.Where(rule => rule.Level <= level)
.OrderBy(rule => rule.Level)
.LastOrDefault()?.Slots ?? SlotRules[0].Slots;
}
public sealed record EnrollmentSlotRule(int Level, int Slots);
@@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data;
public static class AchievementCatalogSeeder
{
private static readonly IReadOnlyList<AchievementSeed> Catalog =
[
new(1001, "Добро пожаловать в UniVerse", "Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.", "sparkles", 10, "first_activity:1"),
new(1002, "Первый шаг", "Посетить первую открытую лекцию.", "book-2", 10, "lectures_attended:1"),
new(1003, "Вошел во вкус", "Посетить 3 открытые лекции.", "books", 20, "lectures_attended:3"),
new(1004, "Постоянный слушатель", "Посетить 5 открытых лекций.", "calendar-event", 35, "lectures_attended:5"),
new(1005, "Академический марафон", "Посетить 10 открытых лекций.", "stopwatch", 60, "lectures_attended:10"),
new(1006, "Грандмастер лекций", "Посетить 25 открытых лекций.", "trophy", 120, "lectures_attended:25"),
new(1007, "Первый отзыв", "Оставить первый отзыв о посещенной лекции.", "message-circle", 10, "reviews_written:1"),
new(1008, "Голос аудитории", "Оставить 3 отзыва о лекциях.", "thumb-up", 25, "reviews_written:3"),
new(1009, "Рецензент", "Оставить 10 отзывов о лекциях.", "clipboard-list", 70, "reviews_written:10"),
new(1010, "Голос перемен", "Оставить 25 отзывов о лекциях.", "chart-line", 150, "reviews_written:25"),
new(1011, "Смелый выбор", "Записаться на первую открытую лекцию.", "calendar", 5, "lectures_registered:1"),
new(1012, "План на неделю", "Иметь 3 активные записи на будущие лекции.", "calendar-event", 15, "active_registrations:3"),
new(1013, "Полный календарь", "Иметь 5 активных записей на будущие лекции.", "alarm", 30, "active_registrations:5"),
new(1014, "Серия интереса", "Посещать открытые лекции 3 недели подряд.", "star", 50, "attendance_streak_weeks:3"),
new(1015, "Учебный месяц", "Посещать открытые лекции 4 недели подряд.", "sparkles", 80, "attendance_streak_weeks:4"),
new(1016, "Без пропусков", "Посетить 5 лекций, на которые была оформлена запись.", "circle-check", 40, "attended_registered:5"),
new(1017, "Надежный участник", "Посетить 10 лекций, на которые была оформлена запись.", "shield", 75, "attended_registered:10"),
new(1018, "Капитал знаний", "Получить 500 монет за активность на платформе.", "coin", 80, "coins_earned:500"),
new(1019, "Новый уровень", "Достигнуть 2 уровня.", "star", 25, "level_reached:2"),
new(1020, "Уверенный рост", "Достигнуть 5 уровня.", "chart-bar", 100, "level_reached:5"),
new(1021, "Профиль заполнен", "Заполнить имя и аватар в профиле.", "user", 10, "profile_completed:1")
];
private static readonly IReadOnlyDictionary<string, string> LegacyConditions = new Dictionary<string, string>
{
["reviews_1"] = "reviews_written:1",
["reviews_5"] = "reviews_written:5",
["reviews_10"] = "reviews_written:10",
["attended_5"] = "lectures_attended:5",
["attended_10"] = "lectures_attended:10"
};
public static async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken = default)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var legacyConditionKeys = LegacyConditions.Keys.ToArray();
var legacyAchievements = await db.Achievements
.Where(a => a.Condition != null && legacyConditionKeys.Contains(a.Condition))
.ToListAsync(cancellationToken);
foreach (var achievement in legacyAchievements)
achievement.Condition = LegacyConditions[achievement.Condition!];
foreach (var seed in Catalog)
{
var achievement = await db.Achievements.FindAsync([seed.Id], cancellationToken);
if (achievement == null)
{
db.Achievements.Add(new Achievement
{
Id = seed.Id,
Name = seed.Name,
Description = seed.Description,
IconUrl = seed.IconUrl,
XpReward = 0,
CoinReward = seed.CoinReward,
Condition = seed.Condition
});
continue;
}
achievement.Name = seed.Name;
achievement.Description = seed.Description;
achievement.IconUrl = seed.IconUrl;
achievement.XpReward = 0;
achievement.CoinReward = seed.CoinReward;
achievement.Condition = seed.Condition;
}
await db.SaveChangesAsync(cancellationToken);
}
private sealed record AchievementSeed(
int Id,
string Name,
string Description,
string IconUrl,
int CoinReward,
string Condition);
}
@@ -10,6 +10,7 @@ public class AppDbContext : DbContext
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; } = null!;
public DbSet<UserRoleAssignment> UserRoles { get; set; } = null!;
public DbSet<StudentProfile> StudentProfiles { get; set; } = null!;
public DbSet<TeacherProfile> TeacherProfiles { get; set; } = null!;
public DbSet<Course> Courses { get; set; } = null!;
@@ -19,9 +20,12 @@ public class AppDbContext : DbContext
public DbSet<CourseTag> CourseTags { get; set; } = null!;
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
public DbSet<Review> Reviews { get; set; } = null!;
public DbSet<ReviewPromptSetting> ReviewPromptSettings { get; set; } = null!;
public DbSet<Achievement> Achievements { get; set; } = null!;
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!;
public DbSet<LevelThreshold> LevelThresholds { get; set; } = null!;
public DbSet<UserNotification> UserNotifications { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
static AppDbContext()
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class LevelThresholdConfiguration : IEntityTypeConfiguration<LevelThreshold>
{
public void Configure(EntityTypeBuilder<LevelThreshold> builder)
{
builder.ToTable("level_thresholds", table =>
{
table.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
table.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
builder.HasKey(t => t.Level);
builder.Property(t => t.Level).HasColumnName("level").ValueGeneratedNever();
builder.Property(t => t.RequiredXp).HasColumnName("required_xp").IsRequired();
builder.HasIndex(t => t.RequiredXp).IsUnique();
builder.HasData(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 },
new LevelThreshold { Level = 4, RequiredXp = 600 },
new LevelThreshold { Level = 5, RequiredXp = 1000 },
new LevelThreshold { Level = 6, RequiredXp = 1500 },
new LevelThreshold { Level = 7, RequiredXp = 2500 },
new LevelThreshold { Level = 8, RequiredXp = 4000 }
);
}
}
@@ -21,6 +21,7 @@ public class ReviewConfiguration : IEntityTypeConfiguration<Review>
builder.Property(r => r.QualityScore).HasColumnName("quality_score");
builder.Property(r => r.IsInformative).HasColumnName("is_informative");
builder.Property(r => r.LlmTags).HasColumnName("llm_tags");
builder.Property(r => r.LlmRawOutput).HasColumnName("llm_raw_output");
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class ReviewPromptSettingConfiguration : IEntityTypeConfiguration<ReviewPromptSetting>
{
public void Configure(EntityTypeBuilder<ReviewPromptSetting> builder)
{
builder.ToTable("review_prompt_settings");
builder.HasKey(setting => setting.Id);
builder.Property(setting => setting.Id)
.HasColumnName("id")
.ValueGeneratedNever();
builder.Property(setting => setting.Prompt)
.HasColumnName("prompt")
.IsRequired();
builder.Property(setting => setting.CreatedAt)
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
builder.Property(setting => setting.UpdatedAt)
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
}
}
@@ -24,5 +24,6 @@ public class TeacherProfileConfiguration : IEntityTypeConfiguration<TeacherProfi
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(t => t.UserId).IsUnique();
builder.HasIndex(t => t.ModeusId).IsUnique().HasFilter("modeus_id IS NOT NULL");
}
}
@@ -15,7 +15,6 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(u => u.Email).HasColumnName("email").HasMaxLength(255).IsRequired();
builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(255);
builder.Property(u => u.AvatarUrl).HasColumnName("avatar_url").HasMaxLength(500);
builder.Property(u => u.Role).HasColumnName("role");
builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true);
builder.Property(u => u.MicrosoftId).HasColumnName("microsoft_id").HasMaxLength(255);
builder.Property(u => u.Xp).HasColumnName("xp").HasDefaultValue(0);
@@ -25,5 +24,10 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
builder.HasIndex(u => u.Email).IsUnique();
builder.HasIndex(u => u.MicrosoftId).IsUnique().HasFilter("microsoft_id IS NOT NULL");
builder.HasMany(u => u.Roles)
.WithOne(ur => ur.User)
.HasForeignKey(ur => ur.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class UserNotificationConfiguration : IEntityTypeConfiguration<UserNotification>
{
public void Configure(EntityTypeBuilder<UserNotification> builder)
{
builder.ToTable("user_notifications");
builder.HasKey(n => n.Id);
builder.Property(n => n.Id).HasColumnName("id");
builder.Property(n => n.UserId).HasColumnName("user_id");
builder.Property(n => n.Type).HasColumnName("type").HasMaxLength(50).IsRequired();
builder.Property(n => n.Title).HasColumnName("title").HasMaxLength(255).IsRequired();
builder.Property(n => n.Body).HasColumnName("body").HasMaxLength(1000).IsRequired();
builder.Property(n => n.IsRead).HasColumnName("is_read").HasDefaultValue(false);
builder.Property(n => n.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.HasOne(n => n.User)
.WithMany(u => u.Notifications)
.HasForeignKey(n => n.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(n => new { n.UserId, n.CreatedAt });
}
}
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class UserRoleAssignmentConfiguration : IEntityTypeConfiguration<UserRoleAssignment>
{
public void Configure(EntityTypeBuilder<UserRoleAssignment> builder)
{
builder.ToTable("user_roles");
builder.HasKey(ur => new { ur.UserId, ur.Role });
builder.Property(ur => ur.UserId).HasColumnName("user_id");
builder.Property(ur => ur.Role).HasColumnName("role");
}
}
@@ -1,9 +1,10 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using UniVerse.Application.Interfaces;
using UniVerse.Application.Prompts;
namespace UniVerse.Infrastructure.ExternalServices;
@@ -11,25 +12,25 @@ public class LlmClient : ILlmClient
{
private readonly HttpClient _http;
private readonly IConfiguration _config;
private readonly IReviewPromptService _reviewPrompts;
private readonly ILogger<LlmClient> _logger;
public LlmClient(HttpClient http, IConfiguration config, ILogger<LlmClient> logger)
public LlmClient(
HttpClient http,
IConfiguration config,
IReviewPromptService reviewPrompts,
ILogger<LlmClient> logger)
{
_http = http; _config = config; _logger = logger;
_http = http;
_config = config;
_reviewPrompts = reviewPrompts;
_logger = logger;
}
public async Task<LlmReviewAnalysis> AnalyzeReviewAsync(string reviewText, string lectureContext)
{
var prompt = $"""
Analyze the following student review of a lecture. Return a JSON object with:
- quality_score: float 0-1 indicating review quality
- sentiment: "Positive", "Neutral", or "Negative"
- tags: array of relevant topic tags
- is_informative: boolean indicating if the review is informative
Lecture context: {lectureContext}
Review text: {reviewText}
""";
var promptSetting = await _reviewPrompts.GetAsync();
var prompt = ReviewPromptTemplate.Render(promptSetting.Prompt, reviewText, lectureContext);
var request = new
{
@@ -49,11 +50,37 @@ public class LlmClient : ILlmClient
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
var content = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!;
var analysis = JsonSerializer.Deserialize<LlmRawResponse>(content,
var analysisJson = NormalizeJsonContent(content);
var analysis = JsonSerializer.Deserialize<LlmRawResponse>(analysisJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
return new LlmReviewAnalysis(analysis.QualityScore, analysis.Sentiment, analysis.Tags, analysis.IsInformative);
return new LlmReviewAnalysis(
Math.Clamp(analysis.QualityScore, 0, 1),
analysis.Sentiment ?? "",
analysis.Tags ?? [],
analysis.IsInformative,
content);
}
private record LlmRawResponse(double QualityScore, string Sentiment, string[] Tags, bool IsInformative);
private static string NormalizeJsonContent(string content)
{
var trimmed = content.Trim();
if (!trimmed.StartsWith("```", StringComparison.Ordinal))
return trimmed;
var firstNewLine = trimmed.IndexOf('\n');
if (firstNewLine < 0)
return trimmed;
var lastFence = trimmed.LastIndexOf("```", StringComparison.Ordinal);
return lastFence > firstNewLine
? trimmed[(firstNewLine + 1)..lastFence].Trim()
: trimmed[(firstNewLine + 1)..].Trim();
}
private record LlmRawResponse(
[property: JsonPropertyName("quality_score")] double QualityScore,
string? Sentiment,
string[]? Tags,
[property: JsonPropertyName("is_informative")] bool IsInformative);
}
@@ -0,0 +1,52 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Client;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Exceptions;
namespace UniVerse.Infrastructure.ExternalServices;
public class MicrosoftAuthClient : IMicrosoftAuthClient
{
private readonly IConfiguration _config;
public MicrosoftAuthClient(IConfiguration config)
{
_config = config;
}
public async Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
string authorizationCode,
string redirectUri,
CancellationToken cancellationToken = default)
{
var tenantId = _config["AzureAd:TenantId"];
var clientId = _config["AzureAd:ClientId"];
var clientSecret = _config["AzureAd:ClientSecret"];
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
if (string.IsNullOrWhiteSpace(tenantId)
|| string.IsNullOrWhiteSpace(clientId)
|| string.IsNullOrWhiteSpace(clientSecret))
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
var authority = $"{instance.TrimEnd('/')}/{tenantId}";
var app = ConfidentialClientApplicationBuilder.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority(new Uri(authority))
.WithRedirectUri(redirectUri)
.Build();
try
{
var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode)
.ExecuteAsync(cancellationToken);
return new MicrosoftTokenResult(result.IdToken);
}
catch (MsalException ex)
{
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
}
}
}
@@ -1,4 +1,7 @@
using System.Net.Http.Json;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using UniVerse.Application.DTOs.Sync;
@@ -21,18 +24,119 @@ public class ModeusApiClient : IModeusApiClient
public async Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request)
{
var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId };
var pageSize = request.Size is > 0 ? request.Size.Value : 900;
var body = new Dictionary<string, object?>
{
["size"] = pageSize,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax
};
AddNonEmpty(body, "roomId", request.RoomId);
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
AddNonEmpty(body, "profileName", request.ProfileName);
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new());
var requestJson = JsonSerializer.Serialize(body);
await EnsureSuccessAsync(response, "Modeus events search",
BuildEventsRequestSummary(requestJson));
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
BuildEventsRequestSummary(requestJson))
?? new ModeusEventsResponse();
}
public async Task<ModeusRoomsResponse> SearchRoomsAsync()
{
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", new { });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new(new());
const int pageSize = 100;
var allRooms = new List<ModeusRoom>();
var page = 0;
var totalPages = 1;
do
{
var body = new
{
name = "",
sort = "+building.name,+name",
size = pageSize,
page,
deleted = false
};
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", body);
await EnsureSuccessAsync(response, "Modeus rooms search",
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false");
var payload = await ReadJsonAsync<ModeusRoomsResponse>(response, "Modeus rooms search",
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false")
?? new ModeusRoomsResponse();
allRooms.AddRange(payload.RoomItems);
totalPages = payload.Page?.TotalPages ?? page + 1;
page++;
}
while (page < totalPages);
return new ModeusRoomsResponse { Rooms = allRooms };
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, string requestSummary)
{
if (response.IsSuccessStatusCode) return;
var responseBody = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {Truncate(responseBody)}",
null,
response.StatusCode);
}
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
IReadOnlyList<T>? values)
{
if (values is { Count: > 0 })
body[key] = values;
}
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, string operation, string requestSummary)
{
var responseBody = await response.Content.ReadAsStringAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "<empty>";
var contentLength = response.Content.Headers.ContentLength?.ToString() ?? "<unknown>";
if (string.IsNullOrWhiteSpace(responseBody))
{
throw new HttpRequestException(
$"{operation} returned HTTP {(int)response.StatusCode} {response.ReasonPhrase} with an empty response body. Request: {requestSummary}. Content-Type: {contentType}. Content-Length: {contentLength}.",
null,
response.StatusCode);
}
try
{
return JsonSerializer.Deserialize<T>(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"{operation} returned invalid JSON. Request: {requestSummary}. Content-Type: {contentType}. Response body: {Truncate(responseBody)}",
ex);
}
}
private static string Truncate(string value) =>
value.Length > 2000 ? string.Concat(value.AsSpan(0, 2000), "...<truncated>") : value;
public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname)
{
@@ -40,4 +144,20 @@ public class ModeusApiClient : IModeusApiClient
$"/api/schedule/searchemployee?fullname={Uri.EscapeDataString(fullname)}");
return response ?? new();
}
public async Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"/api/universe/subid?fullname={Uri.EscapeDataString(fullname)}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
using var response = await _http.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccessAsync(response, "Universe user sub lookup", $"fullname={fullname}");
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return string.IsNullOrWhiteSpace(body) ? null : body.Trim();
}
}
@@ -9,10 +9,10 @@ using UniVerse.Infrastructure.Data;
#nullable disable
namespace UniVerse.Infrastructure.Data.Migrations
namespace UniVerse.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260428124938_Initial")]
[Migration("20260506134139_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace UniVerse.Infrastructure.Data.Migrations
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
@@ -0,0 +1,979 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using UniVerse.Infrastructure.Data;
#nullable disable
namespace UniVerse.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260511011508_UserRolesJoinTable")]
partial class UserRolesJoinTable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_llm_status", "review_llm_status", new[] { "pending", "analyzed", "rejected" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_sentiment", "review_sentiment", new[] { "positive", "neutral", "negative" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tag_type", "tag_type", new[] { "institute", "faculty", "subject", "organization", "topic", "other" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_role", "user_role", new[] { "student", "teacher", "admin" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CoinReward")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("coin_reward");
b.Property<string>("Condition")
.HasColumnType("text")
.HasColumnName("condition");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("IconUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("icon_url");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("name");
b.Property<int>("XpReward")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("xp_reward");
b.HasKey("Id");
b.ToTable("achievements", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("AchievementId")
.HasColumnType("integer")
.HasColumnName("achievement_id");
b.Property<int>("Amount")
.HasColumnType("integer")
.HasColumnName("amount");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("description");
b.Property<int?>("ReviewId")
.HasColumnType("integer")
.HasColumnName("review_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("AchievementId");
b.HasIndex("ReviewId");
b.HasIndex("UserId");
b.ToTable("coin_transactions", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.Course", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("ExternalId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_id");
b.Property<bool>("IsSynced")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_synced");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("name");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("ExternalId")
.IsUnique()
.HasFilter("external_id IS NOT NULL");
b.ToTable("courses", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b =>
{
b.Property<int>("CourseId")
.HasColumnType("integer")
.HasColumnName("course_id");
b.Property<int>("TagId")
.HasColumnType("integer")
.HasColumnName("tag_id");
b.Property<int>("Id")
.HasColumnType("integer")
.HasColumnName("id");
b.HasKey("CourseId", "TagId");
b.HasIndex("TagId");
b.HasIndex("CourseId", "TagId")
.IsUnique();
b.ToTable("course_tags", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CourseId")
.HasColumnType("integer")
.HasColumnName("course_id");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<DateTime>("EndsAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ends_at");
b.Property<string>("ExternalId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_id");
b.Property<int>("Format")
.HasColumnType("integer")
.HasColumnName("format");
b.Property<bool>("IsOpen")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_open");
b.Property<int?>("LocationId")
.HasColumnType("integer")
.HasColumnName("location_id");
b.Property<int>("MaxEnrollments")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("max_enrollments");
b.Property<string>("OnlineUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("online_url");
b.Property<DateTime>("StartsAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("starts_at");
b.Property<int?>("TeacherId")
.HasColumnType("integer")
.HasColumnName("teacher_id");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("CourseId");
b.HasIndex("ExternalId")
.IsUnique()
.HasFilter("external_id IS NOT NULL");
b.HasIndex("LocationId");
b.HasIndex("StartsAt");
b.HasIndex("TeacherId");
b.ToTable("lectures", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b =>
{
b.Property<int>("LectureId")
.HasColumnType("integer")
.HasColumnName("lecture_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.Property<bool>("Attended")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("attended");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<int>("Id")
.HasColumnType("integer")
.HasColumnName("id");
b.HasKey("LectureId", "UserId");
b.HasIndex("UserId");
b.HasIndex("LectureId", "UserId")
.IsUnique();
b.ToTable("lecture_enrollments", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.Location", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("address");
b.Property<string>("Building")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("building");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("ExternalId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("external_id");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("name");
b.Property<string>("Room")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("room");
b.HasKey("Id");
b.HasIndex("ExternalId")
.IsUnique()
.HasFilter("external_id IS NOT NULL");
b.ToTable("locations", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("token");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("refresh_tokens", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.Review", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<bool?>("IsInformative")
.HasColumnType("boolean")
.HasColumnName("is_informative");
b.Property<int>("LectureId")
.HasColumnType("integer")
.HasColumnName("lecture_id");
b.Property<int>("LlmStatus")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("llm_status");
b.PrimitiveCollection<string[]>("LlmTags")
.HasColumnType("text[]")
.HasColumnName("llm_tags");
b.Property<double?>("QualityScore")
.HasColumnType("double precision")
.HasColumnName("quality_score");
b.Property<int>("Rating")
.HasColumnType("integer")
.HasColumnName("rating");
b.Property<int?>("Sentiment")
.HasColumnType("integer")
.HasColumnName("sentiment");
b.Property<string>("Text")
.HasColumnType("text")
.HasColumnName("text");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("LlmStatus");
b.HasIndex("UserId");
b.HasIndex("LectureId", "UserId")
.IsUnique();
b.ToTable("reviews", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("EnrollmentYear")
.HasColumnType("integer")
.HasColumnName("enrollment_year");
b.Property<string>("Faculty")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("faculty");
b.Property<string>("GroupName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("group_name");
b.Property<string>("Specialty")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("specialty");
b.Property<string>("StudentId")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("student_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("student_profiles", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("name");
b.Property<int?>("ParentId")
.HasColumnType("integer")
.HasColumnName("parent_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("tags", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("Department")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("department");
b.Property<string>("ModeusId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("modeus_id");
b.Property<string>("Title")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("title");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("teacher_profiles", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AvatarUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("avatar_url");
b.Property<int>("Coins")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("coins");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("DisplayName")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("display_name");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("email");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("MicrosoftId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("microsoft_id");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
b.Property<int>("Xp")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("xp");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("MicrosoftId")
.IsUnique()
.HasFilter("microsoft_id IS NOT NULL");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.Property<int>("AchievementId")
.HasColumnType("integer")
.HasColumnName("achievement_id");
b.Property<DateTime>("AwardedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("awarded_at")
.HasDefaultValueSql("NOW()");
b.Property<int>("Id")
.HasColumnType("integer")
.HasColumnName("id");
b.HasKey("UserId", "AchievementId");
b.HasIndex("AchievementId");
b.HasIndex("UserId", "AchievementId")
.IsUnique();
b.ToTable("user_achievements", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.HasKey("UserId", "Role");
b.ToTable("user_roles", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b =>
{
b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement")
.WithMany()
.HasForeignKey("AchievementId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("UniVerse.Domain.Entities.Review", "Review")
.WithMany("CoinTransactions")
.HasForeignKey("ReviewId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithMany("CoinTransactions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Achievement");
b.Navigation("Review");
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b =>
{
b.HasOne("UniVerse.Domain.Entities.Course", "Course")
.WithMany("CourseTags")
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("UniVerse.Domain.Entities.Tag", "Tag")
.WithMany("CourseTags")
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Course");
b.Navigation("Tag");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b =>
{
b.HasOne("UniVerse.Domain.Entities.Course", "Course")
.WithMany("Lectures")
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("UniVerse.Domain.Entities.Location", "Location")
.WithMany("Lectures")
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("UniVerse.Domain.Entities.User", "Teacher")
.WithMany()
.HasForeignKey("TeacherId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Course");
b.Navigation("Location");
b.Navigation("Teacher");
});
modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b =>
{
b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture")
.WithMany("Enrollments")
.HasForeignKey("LectureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithMany("Enrollments")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Lecture");
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b =>
{
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Review", b =>
{
b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture")
.WithMany("Reviews")
.HasForeignKey("LectureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithMany("Reviews")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Lecture");
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b =>
{
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithOne("StudentProfile")
.HasForeignKey("UniVerse.Domain.Entities.StudentProfile", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b =>
{
b.HasOne("UniVerse.Domain.Entities.Tag", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Parent");
});
modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b =>
{
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithOne("TeacherProfile")
.HasForeignKey("UniVerse.Domain.Entities.TeacherProfile", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b =>
{
b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement")
.WithMany("UserAchievements")
.HasForeignKey("AchievementId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithMany("UserAchievements")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Achievement");
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b =>
{
b.HasOne("UniVerse.Domain.Entities.User", "User")
.WithMany("Roles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b =>
{
b.Navigation("UserAchievements");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Course", b =>
{
b.Navigation("CourseTags");
b.Navigation("Lectures");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b =>
{
b.Navigation("Enrollments");
b.Navigation("Reviews");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Location", b =>
{
b.Navigation("Lectures");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Review", b =>
{
b.Navigation("CoinTransactions");
});
modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b =>
{
b.Navigation("Children");
b.Navigation("CourseTags");
});
modelBuilder.Entity("UniVerse.Domain.Entities.User", b =>
{
b.Navigation("CoinTransactions");
b.Navigation("Enrollments");
b.Navigation("RefreshTokens");
b.Navigation("Reviews");
b.Navigation("Roles");
b.Navigation("StudentProfile");
b.Navigation("TeacherProfile");
b.Navigation("UserAchievements");
});
#pragma warning restore 612, 618
}
}
}

Some files were not shown because too many files have changed in this diff Show More