124 Commits

Author SHA1 Message Date
Renovate Bot 273d255bce chore(deps): update dependency aspire.apphost.sdk to 13.4.4
Backend CI / build-and-test (pull_request) Successful in 40s
Frontend Playwright / e2e (pull_request) Successful in 11m1s
2026-06-16 05:00:23 +00:00
serega404 57006dd481 docs: обновил документацию README.md
🚀 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) Successful in 14s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
2026-06-02 23:11:23 +03:00
serega404 136bcce7db feat: добавил поддержку подписки на календарь и экспорт расписания лекций в формате .ics
Backend CI / build-and-test (push) Successful in 57s
Frontend CI / build-and-check (push) Failing after 26s
🚀 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 2m33s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 33s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
2026-06-02 22:10:15 +03:00
serega404 7050851bd4 feat: добавил поддержку ограничения частоты запросов
Backend CI / build-and-test (push) Successful in 48s
Frontend CI / build-and-check (push) Failing after 23s
🚀 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 1m21s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 30s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 7s
2026-06-01 12:40:28 +03:00
serega404 450f2e2418 feat: обновил шаблон отзыва и улучшил анализ отзывов преподавателя 2026-06-01 12:31:30 +03:00
serega404 09d3d2778d feat: подтянул занятость из Modeus
Backend CI / build-and-test (push) Successful in 53s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 6m26s
🚀 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 6s
2026-05-30 14:08:49 +03:00
serega404 a8d51df3f1 feat: добавил эндпоинт для получения статистики админского дашборда
Backend CI / build-and-test (push) Successful in 40s
Frontend CI / build-and-check (push) Failing after 19s
🚀 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) Successful in 24s
🚀 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 5s
2026-05-30 01:23:57 +03:00
serega404 e8f7a64d15 Merge pull request 'chore(deps): update dependency aspire.apphost.sdk to 13.3.5' (#26) from renovate/aspire.apphost.sdk-13.x into dev
Backend CI / build-and-test (push) Has been cancelled
🚀 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
Reviewed-on: #26
2026-05-30 01:18:26 +03:00
serega404 c1597a8649 Merge pull request 'chore(deps): update dependency xunit.runner.visualstudio to 3.1.5' (#25) from renovate/xunit.runner.visualstudio-3.x into dev
Backend CI / build-and-test (push) Has been cancelled
🚀 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
Reviewed-on: #25
2026-05-30 01:18:08 +03:00
serega404 f9d3f1ac56 feat: добавил prometheus экспортер
Backend CI / build-and-test (push) Successful in 43s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
Backend CI / build-and-test (pull_request) Successful in 44s
Frontend Playwright / e2e (pull_request) Has been cancelled
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 59s
🚀 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) Successful in 4s
2026-05-30 01:16:58 +03:00
Renovate Bot 62dbeecd9f chore(deps): update dependency aspire.apphost.sdk to 13.3.5
Backend CI / build-and-test (pull_request) Successful in 41s
Frontend Playwright / e2e (pull_request) Successful in 10m13s
2026-05-29 05:00:15 +00:00
Renovate Bot edd276664c chore(deps): update dependency xunit.runner.visualstudio to 3.1.5
Backend CI / build-and-test (pull_request) Successful in 46s
Frontend Playwright / e2e (pull_request) Successful in 10m13s
2026-05-29 05:00:14 +00:00
serega404 7f923cd612 feat: обновил типы в DataTable
Frontend CI / build-and-check (push) Successful in 26s
🚀 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) Successful in 13s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 35s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
Backend CI / build-and-test (pull_request) Successful in 1m6s
Frontend CI / build-and-check (pull_request) Successful in 49s
Frontend Playwright / e2e (pull_request) Successful in 10m53s
2026-05-28 20:00:05 +03:00
serega404 c93d205e34 feat: обновил типы в компонентах
Frontend CI / build-and-check (push) Failing after 19s
🚀 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) Successful in 16s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-28 19:54:38 +03:00
serega404 ef2fd39508 feat: добавил новые юнит-тесты для сервисов и маппинга
Backend CI / build-and-test (push) Successful in 48s
🚀 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 24s
🚀 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 2s
2026-05-28 19:51:34 +03:00
serega404 cce7bea12f fix: исправил типы в DataTable.vue и обновил зависимости Playwright
Frontend CI / build-and-check (push) Failing after 20s
🚀 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) Successful in 10s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 24s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-28 19:28:21 +03:00
serega404 88146f22b6 feat: добавил тесты с использованием Playwright
Frontend CI / build-and-check (push) Failing after 19s
🚀 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 8s
🚀 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 3s
2026-05-28 19:17:11 +03:00
serega404 cb80b35ba6 docs: добавил k6 тестирование
Frontend CI / build-and-check (push) Failing after 19s
🚀 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 10s
🚀 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 2s
2026-05-28 17:54:26 +03:00
serega404 d12dd27323 Merge pull request 'chore(deps): update dependency npgsql.entityframeworkcore.postgresql to 10.0.2' (#23) from renovate/npgsql.entityframeworkcore.postgresql-10.x into dev
Backend CI / build-and-test (push) Successful in 47s
🚀 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 1m3s
🚀 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) Successful in 2s
Reviewed-on: #23
2026-05-28 14:59:05 +03:00
Renovate Bot 3ec0dec229 chore(deps): update dependency npgsql.entityframeworkcore.postgresql to 10.0.2
Backend CI / build-and-test (pull_request) Successful in 37s
2026-05-28 05:00:17 +00:00
serega404 2263c1a7fd Merge pull request 'chore(deps): update dependency microsoft.extensions.http to 10.0.8' (#20) from renovate/microsoft.extensions.http-10.x into dev
Backend CI / build-and-test (push) Successful in 38s
🚀 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 39s
🚀 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) Successful in 3s
Reviewed-on: #20
2026-05-28 03:43:53 +03:00
serega404 cb410d8638 Merge pull request 'chore(deps): update dependency microsoft.extensions.configuration.abstractions to 10.0.8' (#19) from renovate/microsoft.extensions.configuration.abstractions-10.x into dev
Backend CI / build-and-test (push) Successful in 39s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 35s
🚀 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) Successful in 3s
Reviewed-on: #19
2026-05-28 03:43:14 +03:00
serega404 bbebedcee9 Merge pull request 'chore(deps): update dependency microsoft.entityframeworkcore.inmemory to 10.0.8' (#18) from renovate/microsoft.entityframeworkcore.inmemory-10.x into dev
Backend CI / build-and-test (push) Successful in 48s
🚀 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) Successful in 55s
🚀 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) Successful in 4s
Reviewed-on: #18
2026-05-28 03:43:00 +03:00
Renovate Bot b80a30d6a1 chore(deps): update dependency microsoft.extensions.http to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 48s
2026-05-27 05:00:38 +00:00
Renovate Bot f7496bab71 chore(deps): update dependency microsoft.extensions.configuration.abstractions to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 40s
2026-05-26 05:00:34 +00:00
Renovate Bot f03b410e8f chore(deps): update dependency microsoft.entityframeworkcore.inmemory to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 44s
2026-05-26 05:00:33 +00:00
serega404 920d829604 Merge pull request 'chore(deps): update dependency microsoft.entityframeworkcore.design to 10.0.8' (#17) from renovate/microsoft.entityframeworkcore.design-10.x into dev
Backend CI / build-and-test (push) Successful in 44s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m15s
🚀 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) Successful in 3s
Reviewed-on: #17
2026-05-25 19:02:53 +03:00
Renovate Bot 7e782338b9 chore(deps): update dependency microsoft.entityframeworkcore.design to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 40s
2026-05-25 05:00:32 +00:00
serega404 8b5ec6148d Merge pull request 'chore(deps): update dependency microsoft.aspnetcore.mvc.testing to 10.0.8' (#15) from renovate/microsoft.aspnetcore.mvc.testing-10.x into dev
Backend CI / build-and-test (push) Successful in 38s
🚀 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 23s
🚀 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) Successful in 2s
Reviewed-on: #15
2026-05-25 04:02:00 +03:00
serega404 f289c43001 Merge pull request 'chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8' (#14) from renovate/microsoft.aspnetcore.authentication.jwtbearer-10.x into dev
Backend CI / build-and-test (push) Successful in 38s
🚀 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) Successful in 8s
🚀 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) Successful in 3s
Reviewed-on: #14
2026-05-25 04:01:13 +03:00
Renovate Bot fa7cce4962 chore(deps): update dependency microsoft.aspnetcore.mvc.testing to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 52s
2026-05-25 01:00:16 +00:00
Renovate Bot 89782315d7 chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8
Backend CI / build-and-test (pull_request) Successful in 40s
2026-05-25 01:00:15 +00:00
serega404 3ffc4d68d6 chore: обновил конфиг Renovate
Frontend CI / build-and-check (pull_request) Failing after 17s
🚀 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) Successful in 2s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 8s
2026-05-25 03:45:37 +03:00
serega404 dc4dc71c41 chore: обновил Node.js до 24.x
🚀 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) Has been skipped
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 7s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-25 03:39:49 +03:00
serega404 155be4a586 ci: отключил pnpm кэш в 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 / Build & publish frontend image (push) Successful in 7s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-25 03:38:35 +03:00
serega404 fce3044f94 fix: front type check
Frontend CI / build-and-check (push) Has been cancelled
🚀 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 / Build & publish frontend image (push) Successful in 16s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s
2026-05-25 03:35:30 +03:00
serega404 4251b33596 feat: добавил конфиг renovate 2026-05-25 03:32:08 +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
276 changed files with 36741 additions and 436 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
+61
View File
@@ -0,0 +1,61 @@
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: '24.x'
- 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
+40
View File
@@ -0,0 +1,40 @@
name: Frontend Playwright
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build app
run: pnpm build-only
- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium
- name: Run e2e
run: pnpm test:e2e
+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
+231 -78
View File
@@ -1,56 +1,209 @@
# UniVerse
UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
UniVerse - веб-платформа для открытых межнаправленческих лекций университета. Система помогает студентам находить занятия других направлений, записываться на них, получать напоминания, оставлять отзывы, а преподавателям и администраторам - видеть аналитику посещаемости и качества обратной связи.
## Что внутри
Проект состоит из Vue 3 frontend, ASP.NET Core backend и PostgreSQL-хранилища. Backend предоставляет REST API, интегрируется с Microsoft Entra ID, Modeus API и OpenAI-compatible LLM, а frontend реализует отдельные сценарии для ролей `Student`, `Teacher` и `Admin`.
- Расписание/события и сущности: курсы, лекции, аудитории (locations)
- Отзывы студентов с фоновым LLM-анализом (качество/тональность/теги)
- Геймификация: XP/уровни, монеты, достижения
- JWT-аутентификация и роли (`Admin`, `Teacher`, `Student`)
- Swagger/OpenAPI в Development
- [OpenAPI snapshot](backend/UniVerse.Api/openapi.json)
- [Backend notes](docs/backend.md)
- [Frontend E2E tests](docs/playwright-tests.md)
- [Load testing](docs/load-testing-k6.md)
## Технологии
## Возможности
- .NET 10 / ASP.NET Core
- PostgreSQL + EF Core (Npgsql)
- Serilog
- Swagger (Swashbuckle)
### Для студента
## Структура репозитория
- Каталог открытых лекций с поиском, фильтрацией, карточками и деталями занятия.
- Запись на лекцию и отмена записи с учетом лимитов мест и персонального лимита активных записей.
- Личный дашборд: ближайшие лекции, прогресс уровня, XP, монеты, достижения и статистика.
- Мои лекции: список записей, скачивание `.ics` для одной лекции или всего расписания, ссылка календарной подписки.
- Отзывы о лекциях с оценкой `Like`, `Neutral`, `Dislike`.
- Уведомления и профиль пользователя.
Код backend лежит в папке `backend/` и собран в solution `backend/UniVerse.sln`:
### Для преподавателя
- `backend/UniVerse.Api` — Web API (контроллеры, middleware, background services)
- `backend/UniVerse.Application` — DTO, интерфейсы сервисов, маппинги
- `backend/UniVerse.Domain` — доменные сущности/enum/исключения
- `backend/UniVerse.Infrastructure` — EF Core, миграции, реализации сервисов, внешние клиенты
- Дашборд преподавателя по своим занятиям.
- Просмотр списка лекций и записей.
- Аналитика отзывов: тональность, информативность, теги LLM и агрегированные показатели.
- Работа с отзывами без раскрытия лишних персональных данных студентам.
### Для администратора
- Административная панель со статистикой пользователей, лекций, записей и ожидающих LLM-анализа отзывов.
- Управление пользователями: роли `Student`, `Teacher`, `Admin`, блокировка и разблокировка аккаунтов.
- Управление лекциями и создание новых занятий.
- Синхронизация расписания, аудиторий и преподавателей из Modeus.
- Модерация отзывов, повторный запуск LLM-анализа и настройка промпта.
- Управление курсами, тегами, локациями и достижениями через API.
### Платформенные функции
- Microsoft Entra ID login и dev-login для локальной разработки.
- JWT access token и refresh flow через cookie.
- Ролевая защита маршрутов frontend и endpoint-level авторизация backend.
- Фоновая очередь анализа отзывов через LLM.
- Геймификация: XP, уровни, монеты, достижения и транзакции.
- Email-уведомления и планировщик Quartz.
- Rate limiting, Serilog, Prometheus metrics и Swagger UI в Development.
- Aspire AppHost для совместного локального запуска API и Vite frontend.
- Docker Compose для production-окружения с backend, frontend и PostgreSQL.
## Архитектура
```mermaid
flowchart LR
Student[Student UI] --> Frontend[Vue 3 frontend]
Teacher[Teacher UI] --> Frontend
Admin[Admin UI] --> Frontend
Frontend -->|/api/v1 JSON| Api[ASP.NET Core Web API]
Api --> Auth[Auth and RBAC]
Api --> App[Application services]
App --> Domain[Domain model]
App --> Infra[Infrastructure]
Infra --> Db[(PostgreSQL)]
Infra --> Llm[OpenAI-compatible LLM]
Infra --> Modeus[Modeus schedule API]
Infra --> Mail[SMTP email]
Api --> Quartz[Quartz jobs]
Quartz --> Mail
Api --> Metrics[Prometheus /metrics]
```
Backend следует Clean Architecture:
- `UniVerse.Api` - controllers, middleware, Swagger, DI, background services.
- `UniVerse.Application` - DTO, interfaces, service contracts, mappings, prompts.
- `UniVerse.Domain` - entities, enums, domain exceptions, domain services.
- `UniVerse.Infrastructure` - EF Core, migrations, external clients, notification and business service implementations.
- `UniVerse.AppHost` - Aspire host для локального запуска API и frontend.
- `UniVerse.Api.Tests` - unit и integration tests.
Frontend построен на Vue 3, TypeScript, Vite, Pinia и Vue Router:
- `src/views/student` - кабинет студента, каталог, карточка лекции, мои лекции, отзывы, профиль, уведомления.
- `src/views/teacher` - кабинет преподавателя, лекции, аналитика.
- `src/views/admin` - дашборд, пользователи, лекции, отзывы.
- `src/api` - typed API client, DTO-типы и мапперы.
- `src/components/ui` и `src/components/layout` - переиспользуемые UI и layout-компоненты.
## Сценарий записи и анализа отзыва
```mermaid
sequenceDiagram
actor S as Student
participant UI as Vue frontend
participant API as UniVerse API
participant DB as PostgreSQL
participant Q as ReviewAnalysisQueue
participant LLM as LLM API
participant G as GamificationService
S->>UI: Выбирает открытую лекцию
UI->>API: POST /api/v1/lectures/{id}/enroll
API->>DB: Проверяет лимиты и сохраняет запись
API-->>UI: 204 No Content
S->>UI: Оставляет отзыв
UI->>API: POST /api/v1/reviews
API->>DB: Сохраняет отзыв со статусом Pending
API->>Q: Ставит отзыв в очередь анализа
Q->>LLM: Анализ качества, тональности и тегов
LLM-->>Q: Результат анализа
Q->>DB: Обновляет отзыв
Q->>G: Начисляет XP, монеты и достижения
G->>DB: Сохраняет транзакции и награды
```
Основные группы таблиц:
- Пользователи и доступ: `users`, `user_roles`, `student_profiles`, `teacher_profiles`, `refresh_tokens`.
- Расписание: `courses`, `lectures`, `locations`, `tags`, `course_tags`.
- Участие и обратная связь: `lecture_enrollments`, `reviews`, `review_prompt_settings`.
- Геймификация: `achievements`, `user_achievements`, `coin_transactions`, `level_thresholds`.
- Уведомления: `user_notifications`.
## API
Базовый префикс: `/api/v1`.
- `/auth` - Microsoft login, dev-login, refresh, logout, текущий пользователь.
- `/users` - профиль, статистика, роли, активность, записи, достижения, транзакции, `.ics`.
- `/lectures` - каталог лекций, CRUD, запись, посещаемость, отзывы по лекции.
- `/reviews` - создание, список, модерация, повторный анализ, настройка LLM-промпта.
- `/courses` - курсы и привязка тегов.
- `/tags` - теги и дерево тегов.
- `/locations` - аудитории и локации.
- `/achievements` - каталог достижений.
- `/notifications` - уведомления, отметка прочтения, отправка и планирование.
- `/sync` - синхронизация расписания, аудиторий и преподавателей из Modeus.
В Development Swagger UI доступен по адресу `http://localhost:5019/api/docs`.
## Стек
### Frontend
- Vue 3, TypeScript, Vite.
- Pinia, Vue Router.
- Playwright E2E tests.
- Nginx для production-раздачи и проксирования.
### Backend
- .NET 10, ASP.NET Core Web API.
- EF Core 10, Npgsql, PostgreSQL.
- Swashbuckle/OpenAPI.
- Serilog, Prometheus, ASP.NET Core Rate Limiting.
- Quartz для отложенных уведомлений.
- Aspire AppHost.
- xUnit, NSubstitute, WebApplicationFactory.
### Интеграции
- Microsoft Entra ID.
- Modeus schedule API.
- OpenAI-compatible LLM API.
- SMTP email.
## Требования
- .NET SDK 10 (`dotnet --version` должен показать `10.x`)
- PostgreSQL 14+ (или Docker для поднятия Postgres)
- .NET SDK 10.x.
- Node.js `^20.19.0 || >=22.12.0`.
- pnpm.
- PostgreSQL 17+ или Docker.
- Для production - Docker Engine и Docker Compose.
## Конфигурация
Основные настройки лежат в `backend/UniVerse.Api/appsettings.json`:
Backend читает настройки из `backend/UniVerse.Api/appsettings.json`, `appsettings.Development.json` и переменных окружения в формате `Section__Key`.
- `ConnectionStrings:DefaultConnection` — строка подключения к Postgres
- `Jwt:*` — секрет/issuer/audience и сроки жизни токенов
- `Cors:Origins` — origin’ы фронтенда
- `Llm:*` — настройки LLM (OpenAI-compatible)
- `ModeusApi:*` — настройки интеграции с Modeus
Основные секции:
Можно переопределять через переменные окружения в формате `Section__Key`, например:
- `ConnectionStrings:DefaultConnection` - подключение к PostgreSQL.
- `Jwt:*` - issuer, audience, secret и сроки жизни токенов.
- `AzureAd:*` - Microsoft Entra ID.
- `Cors:Origins` - разрешенные origin frontend.
- `RateLimiting:*` - глобальный fixed-window limiter.
- `Llm:*` - base URL, API key, model и параметры анализа отзывов.
- `ModeusApi:*` - base URL, API key и timeout.
- `Email:Smtp:*` - SMTP-настройки для уведомлений.
- `ConnectionStrings__DefaultConnection`
- `Jwt__Secret`
- `Llm__ApiKey`
- `ModeusApi__ApiKey`
Frontend использует переменные:
## Быстрый старт (локально)
- `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`.
- `VITE_API_PROXY_TARGET` - target для Vite proxy при запуске через Aspire.
- `VITE_AUTH_RETURN_URL` - frontend callback URL, по умолчанию `/auth/callback`.
1) Поднять Postgres (пример через Docker):
## Быстрый старт
### 1. Установить зависимости frontend
```bash
pnpm -C frontend install
```
### 2. Поднять PostgreSQL
```bash
docker run --rm --name universe-postgres \
@@ -61,7 +214,7 @@ docker run --rm --name universe-postgres \
postgres:18
```
2) Применить миграции (первый раз потребуется `dotnet-ef`):
### 3. Применить миграции
```bash
dotnet tool install --global dotnet-ef
@@ -72,71 +225,71 @@ dotnet ef database update \
--startup-project UniVerse.Api
```
3) Запустить API:
### 4. Запустить backend
```bash
cd backend
dotnet run --project UniVerse.Api --launch-profile http
dotnet run --project backend/UniVerse.Api --launch-profile http
```
По умолчанию (профиль `http`) API поднимется на `http://localhost:5019`.
Swagger UI доступен в Development по адресу: `http://localhost:5019/swagger`.
API по умолчанию слушает `http://localhost:5019`.
## Запуск в Docker
В `backend/UniVerse.Api/Dockerfile` настроена сборка контейнера API.
### 5. Запустить frontend
```bash
cd backend
docker build -f UniVerse.Api/Dockerfile -t universe-api .
docker run --rm -p 8080:8080 \
-e ASPNETCORE_URLS=http://+:8080 \
-e ConnectionStrings__DefaultConnection="Host=host.docker.internal;Port=5432;Database=universe;Username=postgres;Password=postgres" \
universe-api
pnpm -C frontend dev
```
Примечание: на Linux `host.docker.internal` может быть недоступен — проще запускать Postgres тоже в Docker и соединять контейнеры в одной сети.
Vite frontend по умолчанию слушает `http://localhost:5173` и проксирует `/api` на `http://localhost:5019`.
## Аутентификация
## Запуск через Aspire
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
- `POST /api/v1/auth/login/microsoft` — заготовка под Microsoft Entra ID (сейчас не реализовано).
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
Aspire AppHost запускает API и Vite frontend вместе:
Большинство методов API защищены `[Authorize]`.
```bash
pnpm -C frontend install
dotnet run --project backend/UniVerse.AppHost/UniVerse.AppHost.csproj
```
## Фоновый LLM-анализ отзывов
Frontend обычно доступен на `http://localhost:5173`. Target API передается во frontend через `VITE_API_PROXY_TARGET`.
Сервис `LlmProcessingBackgroundService` раз в ~2 минуты берёт отзывы со статусом `Pending` и прогоняет через LLM-клиент.
LLM-ключ задаётся через `Llm:ApiKey`.
## Docker Compose
Если ключ не задан или внешний сервис недоступен — анализ будет ретраиться, а ошибки логироваться.
Production compose описан в `docker-compose-prod.yml`:
## Интеграция с Modeus
- `app` - ASP.NET Core backend.
- `frontend` - собранный Vue frontend и Nginx.
- `db` - PostgreSQL.
Эндпоинты синхронизации доступны только администратору:
Перед запуском задайте переменные окружения для PostgreSQL, JWT, Microsoft auth, CORS и внешних интеграций:
- `POST /api/v1/sync/schedule`
- `POST /api/v1/sync/rooms`
- `POST /api/v1/sync/employees`
- `GET /api/v1/sync/status`
```bash
docker compose -f docker-compose-prod.yml up -d
```
Ключ (если нужен) задаётся через `ModeusApi:ApiKey`.
Тестовый compose находится в `docker-compose-test.yml`.
## Карта API (high-level)
## Тестирование
Базовый префикс: `/api/v1`.
Backend:
- `/auth` — логин/refresh/logout/me
- `/users` — профиль/статистика/достижения/транзакции (часть методов — только `Admin`)
- `/courses` — курсы и теги (CRUD в основном для `Admin`)
- `/lectures` — лекции, записи, посещаемость, отзывы
- `/reviews` — отзывы (создание студентом; модерация/реанализ для `Admin`)
- `/tags` — теги + дерево тегов
- `/locations` — аудитории/локации
- `/achievements` — достижения
- `/sync` — синхронизация с внешним расписанием (только `Admin`)
```bash
dotnet test backend/UniVerse.sln
```
Точные схемы запросов/ответов удобнее смотреть в Swagger.
Frontend type-check и production build:
```bash
pnpm -C frontend build
```
Frontend E2E с mock API:
```bash
pnpm -C frontend test:e2e
```
Load testing helper:
```bash
node frontend/scripts/loadtest-endpoints.js
```
+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,124 @@
using UniVerse.Application.Mappings;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using Xunit;
namespace UniVerse.Api.Tests.Application;
public class MappingExtensionsTests
{
[Fact]
public void UserMappings_OrderRolesConsistently()
{
var user = new User
{
Id = 1,
Email = "user@test.local",
DisplayName = "User",
AvatarUrl = "avatar.png",
IsActive = true,
Xp = 120,
Coins = 30,
CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc),
Roles =
[
new UserRoleAssignment { Role = UserRole.Teacher },
new UserRoleAssignment { Role = UserRole.Student },
new UserRoleAssignment { Role = UserRole.Admin }
]
};
var dto = user.ToDto(level: 2);
var currentUser = user.ToCurrentUserDto(level: 2);
var auth = user.ToAuthDto();
Assert.Equal(new[] { UserRole.Student, UserRole.Teacher, UserRole.Admin }, dto.Roles);
Assert.Equal(dto.Roles, currentUser.Roles);
Assert.Equal(dto.Roles, auth.Roles);
Assert.Equal(2, dto.Level);
}
[Fact]
public void LectureMappings_UseNavigationFallbacksAndEnrollmentCount()
{
var startsAt = new DateTime(2026, 2, 1, 9, 0, 0, DateTimeKind.Utc);
var lecture = new Lecture
{
Id = 10,
CourseId = 5,
Title = "Offline lecture",
Description = "Description",
Format = LectureFormat.Offline,
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 25,
MandatoryAttendeesCount = 30,
Enrollments =
[
new LectureEnrollment { UserId = 1 },
new LectureEnrollment { UserId = 2 }
]
};
var dto = lecture.ToDto(isEnrolled: true);
var detail = lecture.ToDetailDto(isEnrolled: false);
Assert.Equal("", dto.CourseName);
Assert.Null(dto.TeacherName);
Assert.Null(dto.LocationName);
Assert.Equal(32, dto.EnrollmentsCount);
Assert.True(dto.IsEnrolled);
Assert.False(detail.IsEnrolled);
}
[Fact]
public void ReviewMapping_CopiesAnalysisFields()
{
var review = new Review
{
Id = 7,
LectureId = 3,
UserId = 4,
Rating = ReviewRating.Like,
Text = "Clear and useful",
LlmStatus = ReviewLlmStatus.Analyzed,
Sentiment = ReviewSentiment.Positive,
QualityScore = 0.95,
IsInformative = true,
LlmTags = ["clear", "useful"],
LlmRawOutput = "{\"quality_score\":0.95}",
CreatedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc),
Lecture = new Lecture { Title = "Lecture title" },
User = new User { DisplayName = "Student" }
};
var dto = review.ToDto();
Assert.Equal("Lecture title", dto.LectureTitle);
Assert.Equal("Student", dto.UserName);
Assert.Equal(ReviewSentiment.Positive, dto.Sentiment);
Assert.Equal(0.95, dto.QualityScore);
Assert.True(dto.IsInformative);
Assert.NotNull(dto.LlmTags);
Assert.Equal(["clear", "useful"], dto.LlmTags);
Assert.Equal("{\"quality_score\":0.95}", dto.LlmRawOutput);
}
[Fact]
public void TagTreeMapping_MapsChildrenRecursively()
{
var root = new Tag { Id = 1, Name = "Root", Type = TagType.Topic };
var child = new Tag { Id = 2, Name = "Child", Type = TagType.Subject, ParentId = 1 };
var grandchild = new Tag { Id = 3, Name = "Grandchild", Type = TagType.Other, ParentId = 2 };
root.Children.Add(child);
child.Children.Add(grandchild);
var dto = root.ToTreeDto();
Assert.Equal("Root", dto.Name);
var childDto = Assert.Single(dto.Children);
Assert.Equal("Child", childDto.Name);
Assert.Equal("Grandchild", Assert.Single(childDto.Children).Name);
}
}
@@ -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,321 @@
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/enrollments/calendar-subscription [AnyAuth]", "GET", "api/v1/users/me/enrollments/calendar-subscription", "Student");
yield return E("users/me/enrollments.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments.ics", "Student");
yield return E("users/me/enrollments/{id}.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments/1.ics", "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"}""" };
yield return new object[] { "users/calendar/enrollments/{token}.ics GET", "GET", "api/v1/users/calendar/enrollments/bad-token.ics" };
// 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,121 @@
using Microsoft.EntityFrameworkCore;
using UniVerse.Application.DTOs.Courses;
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.Courses;
public class CourseServiceTests
{
[Fact]
public async Task GetAllAsync_AppliesSearchSyncedTagFiltersAndPagination()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Backend", Type = TagType.Subject },
new Tag { Id = 2, Name = "Frontend", Type = TagType.Subject });
db.Courses.AddRange(
Course(1, "ASP.NET Core", true, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)),
Course(2, "Vue Basics", true, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)),
Course(3, "Advanced ASP.NET", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
db.CourseTags.AddRange(
new CourseTag { CourseId = 1, TagId = 1 },
new CourseTag { CourseId = 2, TagId = 2 },
new CourseTag { CourseId = 3, TagId = 1 });
await db.SaveChangesAsync();
var service = new CourseService(db);
var result = await service.GetAllAsync(new CourseFilterRequest(
TagId: 1,
Search: "asp",
IsSynced: true,
Page: 1,
PageSize: 10));
var item = Assert.Single(result.Items);
Assert.Equal(1, item.Id);
Assert.Equal(1, result.TotalCount);
Assert.Equal(1, result.TotalPages);
Assert.Equal("Backend", Assert.Single(item.Tags).Name);
}
[Fact]
public async Task GetAllAsync_ReturnsRequestedPageMetadata()
{
await using var db = CreateDbContext();
db.Courses.AddRange(
Course(1, "Old", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)),
Course(2, "Middle", false, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)),
Course(3, "Newest", false, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)));
await db.SaveChangesAsync();
var service = new CourseService(db);
var result = await service.GetAllAsync(new CourseFilterRequest(null, null, null, Page: 2, PageSize: 1));
Assert.Equal(3, result.TotalCount);
Assert.Equal(2, result.Page);
Assert.Equal(1, result.PageSize);
Assert.Equal(3, result.TotalPages);
Assert.Equal(2, Assert.Single(result.Items).Id);
}
[Fact]
public async Task AddTagAsync_LinksExistingCourseAndTag()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new CourseService(db);
await service.AddTagAsync(1, 10);
Assert.True(await db.CourseTags.AnyAsync(ct => ct.CourseId == 1 && ct.TagId == 10));
}
[Fact]
public async Task AddTagAsync_ThrowsWhenTagAlreadyLinked()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
db.CourseTags.Add(new CourseTag { CourseId = 1, TagId = 10 });
await db.SaveChangesAsync();
var service = new CourseService(db);
await Assert.ThrowsAsync<ConflictException>(() => service.AddTagAsync(1, 10));
}
[Fact]
public async Task AddTagAsync_ThrowsWhenCourseOrTagMissing()
{
await using var db = CreateDbContext();
db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow));
db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new CourseService(db);
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(404, 10));
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(1, 404));
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"CourseServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static Course Course(int id, string name, bool isSynced, DateTime createdAt) => new()
{
Id = id,
Name = name,
IsSynced = isSynced,
CreatedAt = createdAt
};
}
@@ -0,0 +1,29 @@
using UniVerse.Domain.Services;
using Xunit;
namespace UniVerse.Api.Tests.DomainServices;
public class EnrollmentSlotPolicyTests
{
[Theory]
[InlineData(-1, 3)]
[InlineData(0, 3)]
[InlineData(1, 3)]
[InlineData(2, 3)]
[InlineData(3, 5)]
[InlineData(4, 7)]
[InlineData(10, 7)]
public void GetLimitForLevel_UsesHighestMatchingRuleOrDefault(int level, int expectedSlots)
{
var slots = EnrollmentSlotPolicy.GetLimitForLevel(level);
Assert.Equal(expectedSlots, slots);
}
[Fact]
public void Rules_ExposeConfiguredThresholdsInAscendingOrder()
{
Assert.Equal(new[] { 1, 3, 4 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Level));
Assert.Equal(new[] { 3, 5, 7 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Slots));
}
}
@@ -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,335 @@
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.Domain.Exceptions;
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.GetMyEnrollmentsIcsAsync(Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
stub.GetEnrollmentIcsAsync(Arg.Any<int>(), Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
stub.GetCalendarSubscriptionTokenAsync(Arg.Any<int>()).Returns("test-token");
stub.GetEnrollmentsIcsBySubscriptionTokenAsync("bad-token")
.Returns(Task.FromException<string>(new ForbiddenException("Invalid calendar subscription token.")));
stub.GetEnrollmentsIcsBySubscriptionTokenAsync(Arg.Is<string>(token => token != "bad-token"))
.Returns(Task.FromResult("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"));
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,264 @@
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 EnrollAsync_CountsMandatoryAttendeesTowardLectureCapacity()
{
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 lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.MaxEnrollments = 31;
lecture.MandatoryAttendeesCount = 30;
db.Users.AddRange(
new User { Id = 1, Email = "first@test.local" },
new User { Id = 2, Email = "second@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(1, 2));
}
[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,37 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.RateLimiting;
public class RateLimitingTests
{
[Fact]
public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit()
{
await using var factory = new ApiWebApplicationFactory()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["RateLimiting:PermitLimit"] = "1",
["RateLimiting:WindowSeconds"] = "60",
["RateLimiting:QueueLimit"] = "0"
});
});
});
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student"));
using var firstResponse = await client.GetAsync("api/v1/tags");
using var secondResponse = await client.GetAsync("api/v1/tags");
Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
}
@@ -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,62 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Infrastructure.ExternalServices;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ModeusApiClientTests
{
[Fact]
public async Task SearchEventsAsync_RequestsIctisEndpointWithCounts()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://schedule.test")
};
var config = new ConfigurationBuilder().Build();
var client = new ModeusApiClient(http, config, NullLogger<ModeusApiClient>.Instance);
await client.SearchEventsAsync(new SyncScheduleRequest(
SpecialtyCode: ["09.03.04"],
TimeMin: new DateTime(2026, 4, 30, 21, 0, 0, DateTimeKind.Utc),
TimeMax: new DateTime(2026, 6, 13, 20, 59, 0, DateTimeKind.Utc),
TypeId: ["LECT"],
Size: 50));
Assert.Equal(HttpMethod.Post, handler.RequestMethod);
Assert.Equal("/api/ictis?includeCounts=true", handler.RequestPathAndQuery);
Assert.NotNull(handler.RequestBody);
using var body = JsonDocument.Parse(handler.RequestBody);
Assert.Equal(50, body.RootElement.GetProperty("size").GetInt32());
Assert.Equal("09.03.04", body.RootElement.GetProperty("specialtyCode")[0].GetString());
Assert.Equal("LECT", body.RootElement.GetProperty("typeId")[0].GetString());
}
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpMethod? RequestMethod { get; private set; }
public string? RequestPathAndQuery { get; private set; }
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestMethod = request.Method;
RequestPathAndQuery = request.RequestUri?.PathAndQuery;
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"events":[]}""")
};
}
}
}
@@ -0,0 +1,425 @@
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_SavesMandatoryAttendeesFromIctisStats()
{
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),
IctisStats = new ModeusIctisStats(StudentCount: 30, TeacherCount: 1)
}
],
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: 120, WorkingCapacity: 120)
]
}
});
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(120, lecture.MaxEnrollments);
Assert.Equal(31, lecture.MandatoryAttendeesCount);
}
[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,86 @@
using Microsoft.EntityFrameworkCore;
using UniVerse.Application.DTOs.Tags;
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.Tags;
public class TagServiceTests
{
[Fact]
public async Task GetAllAsync_FiltersByTypeAndParentAndOrdersByName()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Root", Type = TagType.Topic },
new Tag { Id = 2, Name = "Zeta", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 3, Name = "Alpha", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 4, Name = "Other parent", Type = TagType.Subject, ParentId = 99 },
new Tag { Id = 5, Name = "Other type", Type = TagType.Topic, ParentId = 1 });
await db.SaveChangesAsync();
var service = new TagService(db);
var result = await service.GetAllAsync(TagType.Subject, parentId: 1);
Assert.Equal(new[] { "Alpha", "Zeta" }, result.Select(tag => tag.Name));
}
[Fact]
public async Task CreateAsync_ThrowsWhenParentMissing()
{
await using var db = CreateDbContext();
var service = new TagService(db);
await Assert.ThrowsAsync<NotFoundException>(() =>
service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 404)));
}
[Fact]
public async Task CreateAsync_CreatesChildWhenParentExists()
{
await using var db = CreateDbContext();
db.Tags.Add(new Tag { Id = 1, Name = "Parent", Type = TagType.Topic });
await db.SaveChangesAsync();
var service = new TagService(db);
var created = await service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 1));
Assert.Equal("Child", created.Name);
Assert.Equal(1, created.ParentId);
Assert.True(await db.Tags.AnyAsync(tag => tag.Name == "Child" && tag.ParentId == 1));
}
[Fact]
public async Task GetTreeAsync_ReturnsNestedRootTrees()
{
await using var db = CreateDbContext();
db.Tags.AddRange(
new Tag { Id = 1, Name = "Root A", Type = TagType.Topic },
new Tag { Id = 2, Name = "Child A", Type = TagType.Subject, ParentId = 1 },
new Tag { Id = 3, Name = "Grandchild A", Type = TagType.Other, ParentId = 2 },
new Tag { Id = 4, Name = "Root B", Type = TagType.Organization });
await db.SaveChangesAsync();
var service = new TagService(db);
var tree = await service.GetTreeAsync();
Assert.Equal(new[] { "Root A", "Root B" }, tree.Select(tag => tag.Name));
var rootA = tree.Single(tag => tag.Name == "Root A");
var child = Assert.Single(rootA.Children);
Assert.Equal("Child A", child.Name);
Assert.Equal("Grandchild A", Assert.Single(child.Children).Name);
Assert.Empty(tree.Single(tag => tag.Name == "Root B").Children);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -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.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<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.5">
<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,324 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Users;
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.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));
}
[Fact]
public async Task SetRolesAsync_DeduplicatesRolesAndCreatesProfiles()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "user@test.local",
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await service.SetRolesAsync(1, [UserRole.Teacher, UserRole.Teacher, UserRole.Student]);
var user = await db.Users
.Include(u => u.Roles)
.FirstAsync(u => u.Id == 1);
Assert.Equal(new[] { UserRole.Student, UserRole.Teacher }, user.Roles.Select(role => role.Role).OrderBy(role => role));
Assert.Equal(2, user.Roles.Count);
Assert.True(await db.StudentProfiles.AnyAsync(profile => profile.UserId == 1));
Assert.True(await db.TeacherProfiles.AnyAsync(profile => profile.UserId == 1));
}
[Fact]
public async Task SetRolesAsync_RejectsEmptyRoleSet()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "user@test.local",
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.SetRolesAsync(1, []));
}
[Fact]
public async Task SetRolesAsync_PreservesExistingProfiles()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "user@test.local",
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
db.StudentProfiles.Add(new StudentProfile
{
Id = 10,
UserId = 1,
StudentId = "S-1"
});
db.TeacherProfiles.Add(new TeacherProfile
{
Id = 20,
UserId = 1,
Department = "Math"
});
await db.SaveChangesAsync();
var service = CreateService(db);
await service.SetRolesAsync(1, [UserRole.Teacher]);
Assert.Equal(1, await db.StudentProfiles.CountAsync(profile => profile.UserId == 1));
Assert.Equal(1, await db.TeacherProfiles.CountAsync(profile => profile.UserId == 1));
Assert.Equal("S-1", (await db.StudentProfiles.SingleAsync(profile => profile.UserId == 1)).StudentId);
Assert.Equal("Math", (await db.TeacherProfiles.SingleAsync(profile => profile.UserId == 1)).Department);
}
[Fact]
public async Task GetAllAsync_FiltersBySearchActiveAndExactSingleRole()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.AddRange(
User(1, "anna@test.local", "Anna", true, 120, UserRole.Student),
User(2, "anna.teacher@test.local", "Anna Teacher", true, 120, UserRole.Teacher),
User(3, "anna.admin@test.local", "Anna Admin", true, 120, UserRole.Student, UserRole.Admin),
User(4, "inactive@test.local", "Anna Inactive", false, 120, UserRole.Student));
await db.SaveChangesAsync();
var service = CreateService(db);
var result = await service.GetAllAsync(new UserFilterRequest(
Search: "anna",
Role: UserRole.Student,
IsActive: true,
Page: 1,
PageSize: 10));
var user = Assert.Single(result.Items);
Assert.Equal(1, user.Id);
Assert.Equal(2, user.Level);
Assert.Equal(1, result.TotalCount);
}
[Fact]
public async Task GetAllAsync_ReturnsRequestedPageInCreatedAtDescendingOrder()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.AddRange(
User(1, "old@test.local", "Old", true, 0, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UserRole.Student),
User(2, "middle@test.local", "Middle", true, 100, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), UserRole.Student),
User(3, "new@test.local", "New", true, 300, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc), UserRole.Student));
await db.SaveChangesAsync();
var service = CreateService(db);
var result = await service.GetAllAsync(new UserFilterRequest(null, null, null, Page: 2, PageSize: 1));
Assert.Equal(3, result.TotalCount);
Assert.Equal(2, result.Page);
Assert.Equal(3, result.TotalPages);
Assert.Equal(2, Assert.Single(result.Items).Id);
}
[Fact]
public async Task CalendarSubscriptionToken_Roundtrip_ReturnsUserEnrollmentsIcs()
{
await using var db = CreateDbContext();
var startsAt = new DateTime(2026, 1, 10, 9, 0, 0, DateTimeKind.Utc);
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();
var service = CreateService(db);
var token = await service.GetCalendarSubscriptionTokenAsync(1);
var ics = await service.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
Assert.Contains("BEGIN:VCALENDAR", ics);
Assert.Contains("Lecture 1", ics);
}
[Fact]
public async Task CalendarSubscriptionToken_RejectsTamperedToken()
{
await using var db = CreateDbContext();
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
await db.SaveChangesAsync();
var service = CreateService(db);
var token = await service.GetCalendarSubscriptionTokenAsync(1);
var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A');
await Assert.ThrowsAsync<ForbiddenException>(() =>
service.GetEnrollmentsIcsBySubscriptionTokenAsync(tampered));
}
[Fact]
public async Task GetEnrollmentIcsAsync_ReturnsLectureIcsWithoutEnrollment()
{
await using var db = CreateDbContext();
var startsAt = new DateTime(2026, 2, 10, 9, 0, 0, DateTimeKind.Utc);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1853, startsAt));
await db.SaveChangesAsync();
var service = CreateService(db);
var ics = await service.GetEnrollmentIcsAsync(1, 1853);
Assert.Contains("BEGIN:VCALENDAR", ics);
Assert.Contains("Lecture 1853", ics);
}
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);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-calendar-subscription-secret-32chars"
})
.Build();
return new UserService(db, gamification, config);
}
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
};
private static User User(
int id,
string email,
string displayName,
bool isActive,
int xp,
params UserRole[] roles) =>
User(id, email, displayName, isActive, xp, DateTime.UtcNow, roles);
private static User User(
int id,
string email,
string displayName,
bool isActive,
int xp,
DateTime createdAt,
params UserRole[] roles) => new()
{
Id = id,
Email = email,
DisplayName = displayName,
IsActive = isActive,
Xp = xp,
CreatedAt = createdAt,
Roles = roles.Select(role => new UserRoleAssignment { UserId = id, Role = role }).ToList()
};
}
@@ -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();
}
}
@@ -5,74 +5,332 @@ using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using System.Security.Claims;
using System.Text;
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));
[HttpGet("me/enrollments/calendar-subscription")]
[ProducesResponseType(typeof(CalendarSubscriptionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<CalendarSubscriptionDto>> CalendarSubscription()
{
var token = await _users.GetCalendarSubscriptionTokenAsync(CurrentUserId);
var feedUrl = Url.Action(
nameof(CalendarEnrollmentsIcs),
null,
new { token },
Request.Scheme)
?? $"{Request.Scheme}://{Request.Host}/api/v1/users/calendar/enrollments/{token}.ics";
return Ok(new CalendarSubscriptionDto(feedUrl));
}
[AllowAnonymous]
[HttpGet("calendar/enrollments/{token}.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<FileContentResult> CalendarEnrollmentsIcs(string token)
{
var ics = await _users.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
}
[HttpGet("me/enrollments.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<FileContentResult> MyEnrollmentsIcs()
{
var ics = await _users.GetMyEnrollmentsIcsAsync(CurrentUserId);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
}
[HttpGet("me/enrollments/{lectureId:int}.ics")]
[Produces("text/calendar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<FileContentResult> EnrollmentIcs(int lectureId)
{
var ics = await _users.GetEnrollmentIcsAsync(CurrentUserId, lectureId);
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"lecture-{lectureId}.ics");
}
/// <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));
[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();
}
/// <summary>Получить статистику для админского дашборда.</summary>
/// <remarks>Только Admin.</remarks>
/// <response code="200">Агрегированная статистика дашборда.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("admin/stats")]
[ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AdminDashboardStatsDto>> AdminStats() =>
Ok(await _users.GetAdminDashboardStatsAsync());
/// <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")]
[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,71 @@
using System.Net;
using System.Net.Sockets;
namespace UniVerse.Api.Middleware;
public sealed class LocalNetworksOnlyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var remoteIpAddress = context.Connection.RemoteIpAddress;
if (remoteIpAddress is null || !IsLocalNetwork(remoteIpAddress))
{
_logger.LogWarning("Blocked metrics request from non-local address {RemoteIpAddress}", remoteIpAddress);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Metrics endpoint is available only from local networks.");
return;
}
await _next(context);
}
private static bool IsLocalNetwork(IPAddress ipAddress)
{
if (IPAddress.IsLoopback(ipAddress))
{
return true;
}
if (ipAddress.IsIPv4MappedToIPv6)
{
ipAddress = ipAddress.MapToIPv4();
}
return ipAddress.AddressFamily switch
{
AddressFamily.InterNetwork => IsPrivateOrLinkLocalIPv4(ipAddress),
AddressFamily.InterNetworkV6 => IsPrivateOrLinkLocalIPv6(ipAddress),
_ => false
};
}
private static bool IsPrivateOrLinkLocalIPv4(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return bytes[0] == 10
|| bytes[0] == 127
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 169 && bytes[1] == 254);
}
private static bool IsPrivateOrLinkLocalIPv6(IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return ipAddress.IsIPv6LinkLocal
|| ipAddress.IsIPv6SiteLocal
|| (bytes[0] & 0xfe) == 0xfc;
}
}
@@ -0,0 +1,12 @@
namespace UniVerse.Api.Options;
public class RateLimitingOptions
{
public const string SectionName = "RateLimiting";
public int PermitLimit { get; set; } = 600;
public int WindowSeconds { get; set; } = 60;
public int QueueLimit { get; set; } = 100;
}
@@ -0,0 +1,8 @@
namespace UniVerse.Api.Options;
public class ReviewAnalysisOptions
{
public const string SectionName = "Llm:ReviewAnalysis";
public int MaxConcurrentProcessing { get; set; } = 1;
}
+159 -16
View File
@@ -1,19 +1,37 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Prometheus;
using Quartz;
using Serilog;
using System.Threading.RateLimiting;
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 +47,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
npgsql =>
{
npgsql.EnableRetryOnFailure(3);
npgsql.MigrationsAssembly("UniVerse.Infrastructure");
npgsql.MigrationsAssembly("UniVerse.Infrastructure"); // Указывает EF Core, в какой сборке искать/хранить миграции.
});
});
@@ -50,11 +68,55 @@ 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();
builder.Services.AddOptions<RateLimitingOptions>()
.Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName))
.Validate(options => options.PermitLimit >= 1,
"RateLimiting:PermitLimit must be greater than or equal to 1.")
.Validate(options => options.WindowSeconds >= 1,
"RateLimiting:WindowSeconds must be greater than or equal to 1.")
.Validate(options => options.QueueLimit >= 0,
"RateLimiting:QueueLimit must be greater than or equal to 0.")
.ValidateOnStart();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var rateLimitingOptions = context.RequestServices.GetRequiredService<IOptions<RateLimitingOptions>>().Value;
return RateLimitPartition.GetFixedWindowLimiter(
GetRateLimitPartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = rateLimitingOptions.PermitLimit,
Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = rateLimitingOptions.QueueLimit,
AutoReplenishment = true
});
});
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
context.HttpContext.Response.ContentType = "application/problem+json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/429",
title = "Too Many Requests",
status = StatusCodes.Status429TooManyRequests,
detail = "Rate limit exceeded. Please try again later.",
traceId = context.HttpContext.TraceIdentifier
}, cancellationToken);
};
});
// --- CORS ---
builder.Services.AddCors(options =>
{
@@ -77,10 +139,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 =>
@@ -92,11 +184,15 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
client.Timeout = TimeSpan.FromSeconds(30);
client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180));
});
// --- Background Services ---
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
if (!isOpenApiGeneration)
{
builder.Services.AddHostedService<ReviewAnalysisWorker>();
builder.Services.AddHostedService<AchievementCatalogHostedService>();
}
// --- Controllers ---
builder.Services.AddControllers()
@@ -114,9 +210,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 +227,74 @@ 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.UseRateLimiter();
app.UseAuthorization();
app.UseHttpMetrics();
if (app.Environment.IsDevelopment())
{
app.UseAntiforgery();
app.MapQuartzDashboard();
}
app.MapControllers();
// Restrict Prometheus scrape endpoint to local and private networks.
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase),
branch => branch.UseMiddleware<LocalNetworksOnlyMiddleware>());
app.MapMetrics();
app.Run();
static string GetRateLimitPartitionKey(HttpContext context)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub");
if (!string.IsNullOrWhiteSpace(userId))
return $"user:{userId}";
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}";
}
@@ -4,7 +4,8 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchBrowser": true,
"launchUrl": "api/docs",
"applicationUrl": "http://localhost:5019",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
+25 -5
View File
@@ -7,26 +7,36 @@
<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.8">
<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" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.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 +45,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"
}
}
+24 -17
View File
@@ -7,41 +7,48 @@
}
},
"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",
"http://localhost:3000"
]
},
"RateLimiting": {
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 100
},
"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]
"ApiKey": "",
"TimeoutSeconds": 180
},
"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.4.4">
<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,25 @@ 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 AdminDashboardStatsDto(
int UsersCount,
int LecturesCount,
int EnrollmentsCount,
int PendingReviewsCount
);
public record EnrollmentSlotRuleDto(int Level, int Slots);
public record CalendarSubscriptionDto(string FeedUrl);
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,105 @@ 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; }
public ModeusIctisStats? IctisStats { get; init; }
[JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; }
}
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
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,13 @@ public interface IUserService
Task<UserDto> GetByIdAsync(int id);
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
Task<UserStatsDto> GetStatsAsync(int id);
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
Task<string> GetMyEnrollmentsIcsAsync(int userId);
Task<string> GetEnrollmentIcsAsync(int userId, int lectureId);
Task<string> GetCalendarSubscriptionTokenAsync(int userId);
Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token);
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);
}
@@ -13,14 +13,22 @@ namespace UniVerse.Application.Mappings;
public static class MappingExtensions
{
private static int OccupiedSeatsCount(this Lecture lecture) =>
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
// --- 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 +54,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.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new(
@@ -62,7 +70,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
@@ -79,7 +87,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,53 @@
namespace UniVerse.Application.Prompts;
public static class ReviewPromptTemplate
{
public const string LectureContextPlaceholder = "{lectureContext}";
public const string ReviewTextPlaceholder = "{reviewText}";
public const string Default = """
Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю.
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста:
{
"quality_score": 0.0,
"sentiment": "Нейтральный",
"tags": [],
"is_informative": false
}
Правила оценки:
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
Базовые теги:
- "структура лекции"
- "понятность объяснения"
- "темп"
- "сложность"
- "практические примеры"
- "материалы"
- "актуальность темы"
- "вовлеченность"
- "организация"
- "технические проблемы"
- "польза для обучения"
- "неинформативный отзыв"
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
Контекст лекции: {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.8" />
<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>
@@ -15,6 +15,7 @@ public class Lecture
public DateTime EndsAt { get; set; }
public bool IsOpen { get; set; } = true;
public int MaxEnrollments { get; set; }
public int MandatoryAttendeesCount { get; set; }
public string? ExternalId { get; set; }
public string? OnlineUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -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()
@@ -22,6 +22,7 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
builder.Property(l => l.MandatoryAttendeesCount).HasColumnName("mandatory_attendees_count").HasDefaultValue(0);
builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255);
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
@@ -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()");

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