Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d212fba226 | |||
| 57006dd481 | |||
| 136bcce7db | |||
| 7050851bd4 | |||
| 450f2e2418 | |||
| 09d3d2778d | |||
| a8d51df3f1 | |||
| e8f7a64d15 | |||
| c1597a8649 | |||
| f9d3f1ac56 | |||
| 62dbeecd9f | |||
| edd276664c | |||
| 7f923cd612 | |||
| c93d205e34 | |||
| ef2fd39508 | |||
| cce7bea12f | |||
| 88146f22b6 | |||
| cb80b35ba6 | |||
| d12dd27323 | |||
| 3ec0dec229 | |||
| 2263c1a7fd | |||
| cb410d8638 | |||
| bbebedcee9 | |||
| b80a30d6a1 | |||
| f7496bab71 | |||
| f03b410e8f | |||
| 920d829604 | |||
| 7e782338b9 | |||
| 8b5ec6148d | |||
| f289c43001 | |||
| fa7cce4962 | |||
| 89782315d7 | |||
| 3ffc4d68d6 | |||
| dc4dc71c41 | |||
| 155be4a586 | |||
| fce3044f94 | |||
| 4251b33596 | |||
| c4ed23a3d9 | |||
| 98aaa86ec4 | |||
| 24df65a13c | |||
| de52b4ddb8 | |||
| 85ef2a1c22 | |||
| a8a20f9b0b | |||
| 90300b0644 | |||
| e56b577772 | |||
| 99d25adbb1 | |||
| 6aef5dd66f | |||
| 8ac593d36f | |||
| 168d6af860 | |||
| 935e4ed37a | |||
| 27a2811806 | |||
| 32f28898f5 | |||
| 2e7ce6c2e8 | |||
| 32b8bdfd24 | |||
| b52318b992 | |||
| 55369301f0 | |||
| 2e4ccad894 | |||
| 19ea303782 | |||
| 6eeacd80cc | |||
| 934682f035 | |||
| b984d29c50 | |||
| 811b6ef51a | |||
| 302e01d705 | |||
| 2f32df0b1a | |||
| a0ca50a718 | |||
| 926688cd2e | |||
| 373e551bea | |||
| e8a4622fa8 | |||
| 3ba6fe940e | |||
| 6dff7e6ca1 | |||
| dab161ef18 | |||
| 69c726fdc9 | |||
| d37b5933f3 | |||
| e9d232fc22 | |||
| fbec0cc08a | |||
| a42a305a12 | |||
| fef6962fa7 | |||
| 8d4b9ffeec | |||
| 5a1ddb82e6 | |||
| d29b52f824 | |||
| 65e3d1bf18 | |||
| f6aaf0b923 | |||
| 7761238719 | |||
| 98ad8ae74f | |||
| b0a4a6d259 | |||
| feff77b232 | |||
| dbba2be277 | |||
| fcd30f9bf7 | |||
| 17093784e2 | |||
| 462cbb360d | |||
| 860964e3c2 | |||
| 9b28a09253 | |||
| fb8ad6de7c | |||
| 34334e9a8d | |||
| 6824d7ce7d | |||
| 3b0bbfc858 | |||
| a0a0575a99 | |||
| 44234cc42d | |||
| f168050637 | |||
| fc380c7c51 | |||
| 610c15c9fd | |||
| 779b6aba77 | |||
| 71e7d84e0f | |||
| 75282fe8dd | |||
| b42e4e5157 | |||
| 8e376de9f0 | |||
| 331ad86c51 | |||
| a04c20c857 | |||
| aaba62b739 | |||
| 2450361a1d | |||
| 6e473e23d0 | |||
| 32ca5963c8 | |||
| 3af1932480 | |||
| 444415c84b | |||
| 6926565d70 | |||
| 99a9c3bf4d | |||
| 047611fd24 | |||
| 655ab1b5c5 | |||
| e4d47d4dff | |||
| 7c18dbc014 | |||
| 89c81c8e27 | |||
| 8f53fcfe13 | |||
| e64f287ca3 | |||
| 31883c579b |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,17 +1,48 @@
|
|||||||
name: Create and publish a Docker image
|
name: 🚀 Create and publish a Docker image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main', 'staging']
|
branches: ['main', 'dev']
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CONTEXT: ./backend
|
BACKEND_PATH: backend
|
||||||
|
FRONTEND_PATH: frontend
|
||||||
|
SERVER_DOMAIN: ${{ gitea.server_url.replace('https://', '') }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
detect-changes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Publish image
|
name: Detect changes in backend and frontend
|
||||||
container: catthehacker/ubuntu:act-latest
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -24,17 +55,69 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: https://github.com/docker/metadata-action@v4
|
uses: https://github.com/docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}
|
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/backend
|
||||||
- name: Build an image from Dockerfile
|
|
||||||
run: |
|
|
||||||
cd ${{ env.CONTEXT }} &&
|
|
||||||
docker build -f UniVerse.Api/Dockerfile -t ${{ steps.meta.outputs.tags }} .
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
with:
|
with:
|
||||||
registry: ${{ vars.SERVER_DOMAIN }}
|
registry: ${{ vars.SERVER_DOMAIN }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.TOKEN }}
|
password: ${{ secrets.TOKEN }}
|
||||||
- name: Push
|
|
||||||
run: |
|
- name: Build and push Docker image
|
||||||
docker push '${{ steps.meta.outputs.tags }}'
|
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
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ $RECYCLE.BIN/
|
|||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|
||||||
@@ -160,3 +161,4 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
|
backend/UniVerse.Api/appsettings.Development.json
|
||||||
|
|||||||
@@ -1,142 +1,295 @@
|
|||||||
# UniVerse
|
# 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)
|
- [OpenAPI snapshot](backend/UniVerse.Api/openapi.json)
|
||||||
- Отзывы студентов с фоновым LLM-анализом (качество/тональность/теги)
|
- [Backend notes](docs/backend.md)
|
||||||
- Геймификация: XP/уровни, монеты, достижения
|
- [Frontend E2E tests](docs/playwright-tests.md)
|
||||||
- JWT-аутентификация и роли (`Admin`, `Teacher`, `Student`)
|
- [Load testing](docs/load-testing-k6.md)
|
||||||
- Swagger/OpenAPI в Development
|
|
||||||
|
|
||||||
## Технологии
|
## Возможности
|
||||||
|
|
||||||
- .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/исключения
|
- Аналитика отзывов: тональность, информативность, теги LLM и агрегированные показатели.
|
||||||
- `backend/UniVerse.Infrastructure` — EF Core, миграции, реализации сервисов, внешние клиенты
|
- Работа с отзывами без раскрытия лишних персональных данных студентам.
|
||||||
|
|
||||||
|
### Для администратора
|
||||||
|
|
||||||
|
- Административная панель со статистикой пользователей, лекций, записей и ожидающих 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`)
|
- .NET SDK 10.x.
|
||||||
- PostgreSQL 14+ (или Docker для поднятия Postgres)
|
- 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`
|
Frontend использует переменные:
|
||||||
- `Jwt__Secret`
|
|
||||||
- `Llm__ApiKey`
|
|
||||||
- `ModeusApi__ApiKey`
|
|
||||||
|
|
||||||
## Быстрый старт (локально)
|
- `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
|
```bash
|
||||||
docker run --rm --name universe-postgres \
|
docker run --rm --name universe-postgres \
|
||||||
-e POSTGRES_DB=universe \
|
-e POSTGRES_DB=universe \
|
||||||
-e POSTGRES_USER=postgres \
|
-e POSTGRES_USER=postgres \
|
||||||
-e POSTGRES_PASSWORD=postgres \
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
-p 5432:5432 \
|
-p 5432:5432 \
|
||||||
postgres:18
|
postgres:18
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Применить миграции (первый раз потребуется `dotnet-ef`):
|
### 3. Применить миграции
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet tool install --global dotnet-ef
|
dotnet tool install --global dotnet-ef
|
||||||
|
|
||||||
cd backend
|
cd backend
|
||||||
dotnet ef database update \
|
dotnet ef database update \
|
||||||
--project UniVerse.Infrastructure \
|
--project UniVerse.Infrastructure \
|
||||||
--startup-project UniVerse.Api
|
--startup-project UniVerse.Api
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Запустить API:
|
### 4. Запустить backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
dotnet run --project backend/UniVerse.Api --launch-profile http
|
||||||
dotnet run --project UniVerse.Api --launch-profile http
|
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию (профиль `http`) API поднимется на `http://localhost:5019`.
|
API по умолчанию слушает `http://localhost:5019`.
|
||||||
Swagger UI доступен в Development по адресу: `http://localhost:5019/swagger`.
|
|
||||||
|
|
||||||
## Запуск в Docker
|
### 5. Запустить frontend
|
||||||
|
|
||||||
В `backend/UniVerse.Api/Dockerfile` настроена сборка контейнера API.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
pnpm -C frontend dev
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Примечание: на Linux `host.docker.internal` может быть недоступен — проще запускать Postgres тоже в Docker и соединять контейнеры в одной сети.
|
Vite frontend по умолчанию слушает `http://localhost:5173` и проксирует `/api` на `http://localhost:5019`.
|
||||||
|
|
||||||
## Аутентификация
|
## Запуск через Aspire
|
||||||
|
|
||||||
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
|
Aspire AppHost запускает API и Vite frontend вместе:
|
||||||
- `POST /api/v1/auth/login/microsoft` — заготовка под Microsoft Entra ID (сейчас не реализовано).
|
|
||||||
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
|
|
||||||
|
|
||||||
Большинство методов 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-клиент.
|
## Docker Compose
|
||||||
LLM-ключ задаётся через `Llm:ApiKey`.
|
|
||||||
|
|
||||||
Если ключ не задан или внешний сервис недоступен — анализ будет ретраиться, а ошибки логироваться.
|
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`
|
```bash
|
||||||
- `POST /api/v1/sync/rooms`
|
docker compose -f docker-compose-prod.yml up -d
|
||||||
- `POST /api/v1/sync/employees`
|
```
|
||||||
- `GET /api/v1/sync/status`
|
|
||||||
|
|
||||||
Ключ (если нужен) задаётся через `ModeusApi:ApiKey`.
|
Тестовый compose находится в `docker-compose-test.yml`.
|
||||||
|
|
||||||
## Карта API (high-level)
|
## Тестирование
|
||||||
|
|
||||||
Базовый префикс: `/api/v1`.
|
Backend:
|
||||||
|
|
||||||
- `/auth` — логин/refresh/logout/me
|
```bash
|
||||||
- `/users` — профиль/статистика/достижения/транзакции (часть методов — только `Admin`)
|
dotnet test backend/UniVerse.sln
|
||||||
- `/courses` — курсы и теги (CRUD в основном для `Admin`)
|
```
|
||||||
- `/lectures` — лекции, записи, посещаемость, отзывы
|
|
||||||
- `/reviews` — отзывы (создание студентом; модерация/реанализ для `Admin`)
|
|
||||||
- `/tags` — теги + дерево тегов
|
|
||||||
- `/locations` — аудитории/локации
|
|
||||||
- `/achievements` — достижения
|
|
||||||
- `/sync` — синхронизация с внешним расписанием (только `Admin`)
|
|
||||||
|
|
||||||
Точные схемы запросов/ответов удобнее смотреть в 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
|
||||||
|
```
|
||||||
Generated
+1
@@ -0,0 +1 @@
|
|||||||
|
UniVerse
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="UserContentModel">
|
<component name="UserContentModel">
|
||||||
<attachedFolders />
|
<attachedFolders>
|
||||||
|
<Path>../frontend</Path>
|
||||||
|
</attachedFolders>
|
||||||
<explicitIncludes />
|
<explicitIncludes />
|
||||||
<explicitExcludes />
|
<explicitExcludes />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
+6
@@ -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>
|
||||||
@@ -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 <token>".</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;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление определениями достижений системы геймификации.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/achievements")]
|
[Route("api/v1/achievements")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class AchievementsController : ControllerBase
|
public class AchievementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAchievementService _achievements;
|
private readonly IAchievementService _achievements;
|
||||||
|
|
||||||
public AchievementsController(IAchievementService achievements) => _achievements = 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]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<AchievementDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll() => Ok(await _achievements.GetAllAsync());
|
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}")]
|
[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));
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
|
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[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) =>
|
public async Task<ActionResult<AchievementDto>> Update(int id, [FromBody] UpdateAchievementRequest req) =>
|
||||||
Ok(await _achievements.UpdateAsync(id, 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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[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.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using UniVerse.Application.DTOs.Auth;
|
using UniVerse.Application.DTOs.Auth;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
|
using UniVerse.Domain.Enums;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Аутентификация и управление сессией пользователя.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/auth")]
|
[Route("api/v1/auth")]
|
||||||
|
[Produces("application/json")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAuthService _auth;
|
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")]
|
[HttpPost("login/microsoft")]
|
||||||
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
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);
|
SetRefreshTokenCookie(result.RefreshToken);
|
||||||
return Ok(result.Response);
|
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")]
|
[HttpPost("login/dev")]
|
||||||
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
||||||
{
|
{
|
||||||
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
||||||
return NotFound();
|
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);
|
SetRefreshTokenCookie(result.RefreshToken);
|
||||||
return Ok(result.Response);
|
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")]
|
[HttpPost("refresh")]
|
||||||
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<AuthResponse>> Refresh()
|
public async Task<ActionResult<AuthResponse>> Refresh()
|
||||||
{
|
{
|
||||||
var refreshToken = Request.Cookies["refreshToken"];
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
@@ -41,8 +212,17 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(result.Response);
|
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]
|
[Authorize]
|
||||||
[HttpPost("logout")]
|
[HttpPost("logout")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<IActionResult> Logout()
|
public async Task<IActionResult> Logout()
|
||||||
{
|
{
|
||||||
var refreshToken = Request.Cookies["refreshToken"];
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
@@ -52,8 +232,16 @@ public class AuthController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
|
||||||
|
/// <response code="200">Данные текущего пользователя.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
|
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpGet("me")]
|
[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()
|
public async Task<ActionResult> Me()
|
||||||
{
|
{
|
||||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
|
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
@@ -62,12 +250,55 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(user);
|
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)
|
private void SetRefreshTokenCookie(string token)
|
||||||
{
|
{
|
||||||
Response.Cookies.Append("refreshToken", token, new CookieOptions
|
Response.Cookies.Append("refreshToken", token, new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
|
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
|
||||||
Expires = DateTime.UtcNow.AddDays(30)
|
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.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using UniVerse.Application.DTOs.Common;
|
||||||
using UniVerse.Application.DTOs.Courses;
|
using UniVerse.Application.DTOs.Courses;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление курсами (дисциплинами) и их тегами.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/courses")]
|
[Route("api/v1/courses")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class CoursesController : ControllerBase
|
public class CoursesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICourseService _courses;
|
private readonly ICourseService _courses;
|
||||||
|
|
||||||
public CoursesController(ICourseService courses) => _courses = 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]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<CourseDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
|
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
|
||||||
Ok(await _courses.GetAllAsync(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}")]
|
[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));
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
|
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[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) =>
|
public async Task<ActionResult<CourseDto>> Update(int id, [FromBody] UpdateCourseRequest req) =>
|
||||||
Ok(await _courses.UpdateAsync(id, 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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost("{id:int}/tags")]
|
[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)
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}/tags/{tagId:int}")]
|
[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)
|
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;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Каталог лекций — просмотр, управление, запись и отзывы.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/lectures")]
|
[Route("api/v1/lectures")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class LecturesController : ControllerBase
|
public class LecturesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILectureService _lectures;
|
private readonly ILectureService _lectures;
|
||||||
private readonly IReviewService _reviews;
|
private readonly IReviewService _reviews;
|
||||||
|
|
||||||
public LecturesController(ILectureService lectures, 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]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<LectureDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] LectureFilterRequest filter) =>
|
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}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Get(int id) =>
|
public async Task<ActionResult> Get(int id) =>
|
||||||
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
|
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(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")]
|
[Authorize(Roles = "Admin,Teacher")]
|
||||||
[HttpPut("{id:int}")]
|
[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) =>
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[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")]
|
[Authorize(Roles = "Student")]
|
||||||
[HttpPost("{id:int}/enroll")]
|
[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")]
|
[Authorize(Roles = "Student")]
|
||||||
[HttpDelete("{id:int}/enroll")]
|
[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")]
|
[Authorize(Roles = "Admin,Teacher")]
|
||||||
[HttpPatch("{id:int}/attendance/{userId:int}")]
|
[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)
|
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")]
|
[Authorize(Roles = "Admin,Teacher")]
|
||||||
[HttpGet("{id:int}/enrollments")]
|
[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) =>
|
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")]
|
[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) =>
|
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;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление локациями проведения лекций (аудитории, онлайн-площадки).</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/locations")]
|
[Route("api/v1/locations")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class LocationsController : ControllerBase
|
public class LocationsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILocationService _locations;
|
private readonly ILocationService _locations;
|
||||||
|
|
||||||
public LocationsController(ILocationService locations) => _locations = locations;
|
public LocationsController(ILocationService locations) => _locations = locations;
|
||||||
|
|
||||||
|
/// <summary>Получить список всех локаций.</summary>
|
||||||
|
/// <response code="200">Список локаций.</response>
|
||||||
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<LocationDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll() => Ok(await _locations.GetAllAsync());
|
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}")]
|
[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));
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
|
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[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) =>
|
public async Task<ActionResult<LocationDto>> Update(int id, [FromBody] UpdateLocationRequest req) =>
|
||||||
Ok(await _locations.UpdateAsync(id, 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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[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;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Отзывы студентов на лекции с LLM-анализом и модерацией.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/reviews")]
|
[Route("api/v1/reviews")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class ReviewsController : ControllerBase
|
public class ReviewsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IReviewService _reviews;
|
private readonly IReviewService _reviews;
|
||||||
public ReviewsController(IReviewService reviews) => _reviews = reviews;
|
private readonly IReviewPromptService _reviewPrompts;
|
||||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
|
||||||
|
|
||||||
|
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")]
|
[Authorize(Roles = "Student")]
|
||||||
[HttpPost]
|
[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) =>
|
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, 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}")]
|
[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));
|
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}")]
|
[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) =>
|
public async Task<ActionResult<ReviewDto>> Update(int id, [FromBody] UpdateReviewRequest req) =>
|
||||||
Ok(await _reviews.UpdateAsync(id, CurrentUserId, 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}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
|
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize(Roles = "Admin")]
|
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
|
||||||
[HttpGet("pending")]
|
/// <remarks>
|
||||||
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
|
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его
|
||||||
Ok(await _reviews.GetPendingAsync(pagination));
|
/// на повторную обработку.
|
||||||
|
/// </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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost("{id:int}/reanalyze")]
|
[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;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Синхронизация данных из внешней системы расписания Modeus (только Admin).</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/sync")]
|
[Route("api/v1/sync")]
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Produces("application/json")]
|
||||||
public class SyncController : ControllerBase
|
public class SyncController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IScheduleSyncService _sync;
|
private readonly IScheduleSyncService _sync;
|
||||||
|
|
||||||
public SyncController(IScheduleSyncService sync) => _sync = 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")]
|
[HttpPost("schedule")]
|
||||||
|
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
|
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
|
||||||
Ok(await _sync.SyncScheduleAsync(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")]
|
[HttpGet("status")]
|
||||||
|
[ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<SyncStatusDto>> Status() =>
|
public async Task<ActionResult<SyncStatusDto>> Status() =>
|
||||||
Ok(await _sync.GetLastSyncStatusAsync());
|
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")]
|
[HttpPost("rooms")]
|
||||||
|
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
|
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
|
||||||
Ok(await _sync.SyncRoomsAsync());
|
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")]
|
[HttpPost("employees")]
|
||||||
|
[ProducesResponseType(typeof(List<EmployeeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
|
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
|
||||||
Ok(await _sync.SearchEmployeesAsync(fullname));
|
Ok(await _sync.SearchEmployeesAsync(fullname));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,35 +6,101 @@ using UniVerse.Domain.Enums;
|
|||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление тегами для категоризации курсов (институты, факультеты, темы и др.).</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tags")]
|
[Route("api/v1/tags")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class TagsController : ControllerBase
|
public class TagsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITagService _tags;
|
private readonly ITagService _tags;
|
||||||
|
|
||||||
public TagsController(ITagService tags) => _tags = 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]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<TagDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
|
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
|
||||||
Ok(await _tags.GetAllAsync(type, 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}")]
|
[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));
|
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")]
|
[HttpGet("tree")]
|
||||||
|
[ProducesResponseType(typeof(List<TagTreeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
public async Task<ActionResult> GetTree() => Ok(await _tags.GetTreeAsync());
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
|
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
|
||||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPut("{id:int}")]
|
[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) =>
|
public async Task<ActionResult<TagDto>> Update(int id, [FromBody] UpdateTagRequest req) =>
|
||||||
Ok(await _tags.UpdateAsync(id, 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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpDelete("{id:int}")]
|
[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.Application.Interfaces;
|
||||||
using UniVerse.Domain.Enums;
|
using UniVerse.Domain.Enums;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Управление пользователями, профилями и геймификацией.</summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/users")]
|
[Route("api/v1/users")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserService _users;
|
private readonly IUserService _users;
|
||||||
private readonly IReviewService _reviews;
|
private readonly IReviewService _reviews;
|
||||||
private readonly IGamificationService _gamification;
|
private readonly IGamificationService _gamification;
|
||||||
|
|
||||||
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
|
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
|
||||||
{
|
{
|
||||||
_users = users; _reviews = reviews; _gamification = gamification;
|
_users = users; _reviews = reviews; _gamification = gamification;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
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}")]
|
[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));
|
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}")]
|
[HttpPut("{id:int}")]
|
||||||
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
|
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||||
{
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
return Ok(await _users.UpdateProfileAsync(id, req));
|
[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")]
|
[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));
|
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
||||||
|
|
||||||
[HttpGet("{id:int}/enrollments")]
|
/// <summary>Получить статистику для админского дашборда.</summary>
|
||||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
|
/// <remarks>Только Admin.</remarks>
|
||||||
{
|
/// <response code="200">Агрегированная статистика дашборда.</response>
|
||||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
// Delegate to lecture service would be more proper, but returning reviews for now
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
return Ok();
|
[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")]
|
[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) =>
|
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _reviews.GetByUserAsync(id, 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")]
|
[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) =>
|
public async Task<ActionResult> Achievements(int id) =>
|
||||||
Ok(await _gamification.GetUserAchievementsAsync(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")]
|
[HttpGet("{id:int}/transactions")]
|
||||||
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
|
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
|
||||||
{
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<UserDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
|
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
|
||||||
Ok(await _users.GetAllAsync(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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPatch("{id:int}/role")]
|
[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();
|
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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPatch("{id:int}/active")]
|
[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)
|
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
|
||||||
{
|
{
|
||||||
await _users.SetActiveAsync(id, 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
|
var (statusCode, title) = exception switch
|
||||||
{
|
{
|
||||||
|
BadRequestException => ((int)HttpStatusCode.BadRequest, "Bad Request"),
|
||||||
NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"),
|
NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"),
|
||||||
ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"),
|
ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"),
|
||||||
ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"),
|
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;
|
||||||
|
}
|
||||||
+165
-22
@@ -1,19 +1,37 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
using Prometheus;
|
||||||
|
using Quartz;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
|
using UniVerse.Api.Filters;
|
||||||
using UniVerse.Api.Middleware;
|
using UniVerse.Api.Middleware;
|
||||||
|
using UniVerse.Api.Options;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
using UniVerse.Infrastructure.ExternalServices;
|
using UniVerse.Infrastructure.ExternalServices;
|
||||||
|
using UniVerse.Infrastructure.Notifications;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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 ---
|
// --- Serilog ---
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
@@ -29,7 +47,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
|||||||
npgsql =>
|
npgsql =>
|
||||||
{
|
{
|
||||||
npgsql.EnableRetryOnFailure(3);
|
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"],
|
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(
|
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.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 ---
|
// --- CORS ---
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@@ -77,10 +139,40 @@ builder.Services.AddScoped<ILocationService, LocationService>();
|
|||||||
builder.Services.AddScoped<ICourseService, CourseService>();
|
builder.Services.AddScoped<ICourseService, CourseService>();
|
||||||
builder.Services.AddScoped<ILectureService, LectureService>();
|
builder.Services.AddScoped<ILectureService, LectureService>();
|
||||||
builder.Services.AddScoped<IReviewService, ReviewService>();
|
builder.Services.AddScoped<IReviewService, ReviewService>();
|
||||||
|
builder.Services.AddScoped<IReviewPromptService, ReviewPromptService>();
|
||||||
builder.Services.AddScoped<IGamificationService, GamificationService>();
|
builder.Services.AddScoped<IGamificationService, GamificationService>();
|
||||||
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
||||||
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
||||||
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
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 ---
|
// --- HTTP Clients ---
|
||||||
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
||||||
@@ -92,11 +184,15 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
|||||||
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
|
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 ---
|
// --- Background Services ---
|
||||||
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
|
if (!isOpenApiGeneration)
|
||||||
|
{
|
||||||
|
builder.Services.AddHostedService<ReviewAnalysisWorker>();
|
||||||
|
builder.Services.AddHostedService<AchievementCatalogHostedService>();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Controllers ---
|
// --- Controllers ---
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
@@ -112,46 +208,93 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
{
|
{
|
||||||
options.SwaggerDoc("v1", new OpenApiInfo
|
options.SwaggerDoc("v1", new OpenApiInfo
|
||||||
{
|
{
|
||||||
Title = "UniVerse API",
|
Title = "UniVerse API",
|
||||||
Version = "v1",
|
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
|
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
{
|
{
|
||||||
Name = "Authorization",
|
Name = "Authorization",
|
||||||
Type = SecuritySchemeType.Http,
|
Type = SecuritySchemeType.Http,
|
||||||
Scheme = "bearer",
|
Scheme = "bearer",
|
||||||
BearerFormat = "JWT",
|
BearerFormat = "JWT",
|
||||||
In = ParameterLocation.Header,
|
In = ParameterLocation.Header,
|
||||||
Description = "Enter your JWT token"
|
Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`"
|
||||||
});
|
});
|
||||||
|
|
||||||
options.AddSecurityRequirement(doc =>
|
// Include XML doc comments generated from controller /// summaries
|
||||||
{
|
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||||
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
|
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||||
return new OpenApiSecurityRequirement
|
if (File.Exists(xmlPath))
|
||||||
{
|
options.IncludeXmlComments(xmlPath);
|
||||||
[bearerSchemeRef] = new List<string>()
|
|
||||||
};
|
// Per-endpoint security requirement + role documentation (replaces global AddSecurityRequirement)
|
||||||
});
|
options.OperationFilter<AuthorizeOperationFilter>();
|
||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (useAspire)
|
||||||
|
{
|
||||||
|
app.MapDefaultEndpoints();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Middleware Pipeline ---
|
// --- Middleware Pipeline ---
|
||||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
app.UseStaticFiles();
|
||||||
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "UniVerse API v1"));
|
|
||||||
|
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.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseHttpMetrics();
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseAntiforgery();
|
||||||
|
app.MapQuartzDashboard();
|
||||||
|
}
|
||||||
app.MapControllers();
|
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();
|
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": {
|
"http": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "api/docs",
|
||||||
"applicationUrl": "http://localhost:5019",
|
"applicationUrl": "http://localhost:5019",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
|||||||
@@ -7,26 +7,36 @@
|
|||||||
<RootNamespace>UniVerse.Api</RootNamespace>
|
<RootNamespace>UniVerse.Api</RootNamespace>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
<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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
|
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
|
||||||
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" 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="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\UniVerse.Application\UniVerse.Application.csproj" />
|
<ProjectReference Include="..\UniVerse.Application\UniVerse.Application.csproj" />
|
||||||
<ProjectReference Include="..\UniVerse.Infrastructure\UniVerse.Infrastructure.csproj" />
|
<ProjectReference Include="..\UniVerse.Infrastructure\UniVerse.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -35,4 +45,14 @@
|
|||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target
|
||||||
|
Name="CopyGeneratedOpenApiDocument"
|
||||||
|
AfterTargets="Build"
|
||||||
|
Condition="Exists('$(OpenApiDocumentsDirectory)/openapi.json')">
|
||||||
|
<Copy
|
||||||
|
SourceFiles="$(OpenApiDocumentsDirectory)/openapi.json"
|
||||||
|
DestinationFiles="$(MSBuildProjectDirectory)/openapi.json"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,5 +4,23 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,41 +7,48 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"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": {
|
"Cors": {
|
||||||
"Origins": [
|
"Origins": [
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:3000"
|
"http://localhost:3000"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RateLimiting": {
|
||||||
|
"PermitLimit": 600,
|
||||||
|
"WindowSeconds": 60,
|
||||||
|
"QueueLimit": 100
|
||||||
|
},
|
||||||
"Llm": {
|
"Llm": {
|
||||||
"BaseUrl": "https://api.openai.com/v1/",
|
"BaseUrl": "https://api.openai.com/v1/",
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
"Model": "gpt-4o-mini"
|
"Model": "gpt-4o-mini",
|
||||||
|
"ReviewAnalysis": {
|
||||||
|
"MaxConcurrentProcessing": 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ModeusApi": {
|
"ModeusApi": {
|
||||||
"BaseUrl": "https://schedule.rdcenter.ru",
|
"BaseUrl": "https://schedule.rdcenter.ru",
|
||||||
"ApiKey": ""
|
"ApiKey": "",
|
||||||
},
|
"TimeoutSeconds": 180
|
||||||
"Gamification": {
|
|
||||||
"XpThresholds": [0, 100, 300, 600, 1000, 1500, 2500, 4000]
|
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Override": {
|
"Override": {
|
||||||
"Microsoft": "Warning",
|
"Microsoft": "Information",
|
||||||
"System": "Warning"
|
"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
@@ -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.3.5">
|
||||||
|
|
||||||
|
<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 AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User);
|
||||||
public record AuthResult(AuthResponse Response, string RefreshToken);
|
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,
|
string? Description,
|
||||||
DateTime CreatedAt
|
DateTime CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record LevelProgressDto(
|
||||||
|
int CurrentLevelXp,
|
||||||
|
int? NextLevelXp
|
||||||
|
);
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ public record LectureDto(
|
|||||||
int MaxEnrollments,
|
int MaxEnrollments,
|
||||||
int EnrollmentsCount,
|
int EnrollmentsCount,
|
||||||
string? OnlineUrl,
|
string? OnlineUrl,
|
||||||
DateTime CreatedAt
|
DateTime CreatedAt,
|
||||||
|
bool IsEnrolled = false
|
||||||
);
|
);
|
||||||
|
|
||||||
public record LectureDetailDto(
|
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,
|
double? QualityScore,
|
||||||
bool? IsInformative,
|
bool? IsInformative,
|
||||||
string[]? LlmTags,
|
string[]? LlmTags,
|
||||||
|
string? LlmRawOutput,
|
||||||
DateTime CreatedAt
|
DateTime CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text);
|
public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text);
|
||||||
|
|
||||||
public record UpdateReviewRequest(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;
|
namespace UniVerse.Application.DTOs.Sync;
|
||||||
|
|
||||||
public record SyncScheduleRequest(
|
public record SyncScheduleRequest(
|
||||||
string? SpecialtyCode,
|
IReadOnlyList<string>? SpecialtyCode,
|
||||||
DateTime? TimeMin,
|
DateTime? TimeMin,
|
||||||
DateTime? TimeMax,
|
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);
|
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public record UserDto(
|
|||||||
string Email,
|
string Email,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? AvatarUrl,
|
string? AvatarUrl,
|
||||||
UserRole Role,
|
IReadOnlyList<UserRole> Roles,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
int Xp,
|
int Xp,
|
||||||
int Coins,
|
int Coins,
|
||||||
@@ -15,6 +15,18 @@ public record UserDto(
|
|||||||
DateTime CreatedAt
|
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(
|
public record UserStatsDto(
|
||||||
int TotalLectures,
|
int TotalLectures,
|
||||||
int AttendedLectures,
|
int AttendedLectures,
|
||||||
@@ -22,9 +34,25 @@ public record UserStatsDto(
|
|||||||
int Xp,
|
int Xp,
|
||||||
int Coins,
|
int Coins,
|
||||||
int Level,
|
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(
|
public record UpdateUserRequest(
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? AvatarUrl
|
string? AvatarUrl
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ namespace UniVerse.Application.Interfaces;
|
|||||||
|
|
||||||
public interface IAuthService
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode);
|
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null);
|
||||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
|
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
|
||||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||||
Task RevokeRefreshTokenAsync(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,
|
Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
|
||||||
int? reviewId = null, int? achievementId = null, string? description = null);
|
int? reviewId = null, int? achievementId = null, string? description = null);
|
||||||
Task CheckAndAwardAchievementsAsync(int userId);
|
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<List<UserAchievementDto>> GetUserAchievementsAsync(int userId);
|
||||||
Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination);
|
Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ namespace UniVerse.Application.Interfaces;
|
|||||||
|
|
||||||
public interface ILectureService
|
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<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null);
|
||||||
Task<LectureDto> CreateAsync(CreateLectureRequest request);
|
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 DeleteAsync(int id);
|
||||||
Task EnrollAsync(int lectureId, int userId);
|
Task EnrollAsync(int lectureId, int userId);
|
||||||
Task UnenrollAsync(int lectureId, int userId);
|
Task UnenrollAsync(int lectureId, int userId);
|
||||||
Task MarkAttendanceAsync(int lectureId, int userId, bool attended);
|
Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false);
|
||||||
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination);
|
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,4 @@ namespace UniVerse.Application.Interfaces;
|
|||||||
public interface ILlmAnalysisService
|
public interface ILlmAnalysisService
|
||||||
{
|
{
|
||||||
Task AnalyzeReviewAsync(int reviewId);
|
Task AnalyzeReviewAsync(int reviewId);
|
||||||
Task ProcessPendingReviewsAsync();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ public record LlmReviewAnalysis(
|
|||||||
double QualityScore,
|
double QualityScore,
|
||||||
string Sentiment,
|
string Sentiment,
|
||||||
string[] Tags,
|
string[] Tags,
|
||||||
bool IsInformative
|
bool IsInformative,
|
||||||
|
string RawOutput
|
||||||
);
|
);
|
||||||
|
|
||||||
public interface ILlmClient
|
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> GetByIdAsync(int id);
|
||||||
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
|
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
|
||||||
Task DeleteAsync(int id, int userId, bool isAdmin = false);
|
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>> GetByUserAsync(int userId, PaginationRequest pagination);
|
||||||
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
|
||||||
Task ReanalyzeAsync(int id);
|
Task ReanalyzeAsync(int id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using UniVerse.Application.DTOs.Sync;
|
using UniVerse.Application.DTOs.Sync;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace UniVerse.Application.Interfaces;
|
namespace UniVerse.Application.Interfaces;
|
||||||
|
|
||||||
@@ -15,11 +16,105 @@ public interface IModeusApiClient
|
|||||||
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
|
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
|
||||||
Task<ModeusRoomsResponse> SearchRoomsAsync();
|
Task<ModeusRoomsResponse> SearchRoomsAsync();
|
||||||
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
|
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
|
||||||
|
Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modeus API response models
|
// Modeus API response models
|
||||||
public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId);
|
public class ModeusEvent
|
||||||
public record ModeusEventsResponse(List<ModeusEvent> Events);
|
{
|
||||||
public record ModeusRoom(string Id, string Name, string? Building);
|
public string Id { get; init; } = string.Empty;
|
||||||
public record ModeusRoomsResponse(List<ModeusRoom> Rooms);
|
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);
|
public record ModeusEmployee(string? Id, string FullName, string? Department);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using UniVerse.Application.DTOs.Common;
|
using UniVerse.Application.DTOs.Common;
|
||||||
|
using UniVerse.Application.DTOs.Lectures;
|
||||||
using UniVerse.Application.DTOs.Users;
|
using UniVerse.Application.DTOs.Users;
|
||||||
using UniVerse.Domain.Enums;
|
using UniVerse.Domain.Enums;
|
||||||
|
|
||||||
@@ -9,7 +10,13 @@ public interface IUserService
|
|||||||
Task<UserDto> GetByIdAsync(int id);
|
Task<UserDto> GetByIdAsync(int id);
|
||||||
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
||||||
Task<UserStatsDto> GetStatsAsync(int id);
|
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<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
||||||
Task SetRoleAsync(int id, UserRole role);
|
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
||||||
Task SetActiveAsync(int id, bool isActive);
|
Task SetActiveAsync(int id, bool isActive);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,22 @@ namespace UniVerse.Application.Mappings;
|
|||||||
|
|
||||||
public static class MappingExtensions
|
public static class MappingExtensions
|
||||||
{
|
{
|
||||||
|
private static int OccupiedSeatsCount(this Lecture lecture) =>
|
||||||
|
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
|
||||||
|
|
||||||
// --- User ---
|
// --- User ---
|
||||||
public static UserDto ToDto(this User user, int level) => new(
|
public static UserDto ToDto(this User user, int level) => new(
|
||||||
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
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(
|
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 ---
|
// --- Tag ---
|
||||||
@@ -46,14 +54,14 @@ public static class MappingExtensions
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- Lecture ---
|
// --- 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.Id, lecture.CourseId, lecture.Course?.Name ?? "",
|
||||||
lecture.TeacherId, lecture.Teacher?.DisplayName,
|
lecture.TeacherId, lecture.Teacher?.DisplayName,
|
||||||
lecture.LocationId, lecture.Location?.Name,
|
lecture.LocationId, lecture.Location?.Name,
|
||||||
lecture.Title, lecture.Description, lecture.Format,
|
lecture.Title, lecture.Description, lecture.Format,
|
||||||
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
||||||
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
|
||||||
lecture.OnlineUrl, lecture.CreatedAt
|
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||||
);
|
);
|
||||||
|
|
||||||
public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new(
|
public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new(
|
||||||
@@ -62,7 +70,7 @@ public static class MappingExtensions
|
|||||||
lecture.LocationId, lecture.Location?.Name,
|
lecture.LocationId, lecture.Location?.Name,
|
||||||
lecture.Title, lecture.Description, lecture.Format,
|
lecture.Title, lecture.Description, lecture.Format,
|
||||||
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
||||||
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
|
||||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,7 +87,7 @@ public static class MappingExtensions
|
|||||||
review.UserId, review.User?.DisplayName,
|
review.UserId, review.User?.DisplayName,
|
||||||
review.Rating, review.Text, review.LlmStatus,
|
review.Rating, review.Text, review.LlmStatus,
|
||||||
review.Sentiment, review.QualityScore, review.IsInformative,
|
review.Sentiment, review.QualityScore, review.IsInformative,
|
||||||
review.LlmTags, review.CreatedAt
|
review.LlmTags, review.LlmRawOutput, review.CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Achievement ---
|
// --- 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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public class Lecture
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public bool IsOpen { get; set; } = true;
|
public bool IsOpen { get; set; } = true;
|
||||||
public int MaxEnrollments { get; set; }
|
public int MaxEnrollments { get; set; }
|
||||||
|
public int MandatoryAttendeesCount { get; set; }
|
||||||
public string? ExternalId { get; set; }
|
public string? ExternalId { get; set; }
|
||||||
public string? OnlineUrl { get; set; }
|
public string? OnlineUrl { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
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 double? QualityScore { get; set; }
|
||||||
public bool? IsInformative { get; set; }
|
public bool? IsInformative { get; set; }
|
||||||
public string[]? LlmTags { get; set; }
|
public string[]? LlmTags { get; set; }
|
||||||
|
public string? LlmRawOutput { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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;
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ public class User
|
|||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
public string? DisplayName { get; set; }
|
public string? DisplayName { get; set; }
|
||||||
public string? AvatarUrl { get; set; }
|
public string? AvatarUrl { get; set; }
|
||||||
public UserRole Role { get; set; } = UserRole.Student;
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public string? MicrosoftId { get; set; }
|
public string? MicrosoftId { get; set; }
|
||||||
public int Xp { get; set; }
|
public int Xp { get; set; }
|
||||||
@@ -19,9 +18,11 @@ public class User
|
|||||||
// Navigation properties
|
// Navigation properties
|
||||||
public StudentProfile? StudentProfile { get; set; }
|
public StudentProfile? StudentProfile { get; set; }
|
||||||
public TeacherProfile? TeacherProfile { 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<LectureEnrollment> Enrollments { get; set; } = new List<LectureEnrollment>();
|
||||||
public ICollection<Review> Reviews { get; set; } = new List<Review>();
|
public ICollection<Review> Reviews { get; set; } = new List<Review>();
|
||||||
public ICollection<UserAchievement> UserAchievements { get; set; } = new List<UserAchievement>();
|
public ICollection<UserAchievement> UserAchievements { get; set; } = new List<UserAchievement>();
|
||||||
public ICollection<CoinTransaction> CoinTransactions { get; set; } = new List<CoinTransaction>();
|
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>();
|
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 AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<User> Users { get; set; } = null!;
|
public DbSet<User> Users { get; set; } = null!;
|
||||||
|
public DbSet<UserRoleAssignment> UserRoles { get; set; } = null!;
|
||||||
public DbSet<StudentProfile> StudentProfiles { get; set; } = null!;
|
public DbSet<StudentProfile> StudentProfiles { get; set; } = null!;
|
||||||
public DbSet<TeacherProfile> TeacherProfiles { get; set; } = null!;
|
public DbSet<TeacherProfile> TeacherProfiles { get; set; } = null!;
|
||||||
public DbSet<Course> Courses { 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<CourseTag> CourseTags { get; set; } = null!;
|
||||||
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
|
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
|
||||||
public DbSet<Review> Reviews { 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<Achievement> Achievements { get; set; } = null!;
|
||||||
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
|
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
|
||||||
public DbSet<CoinTransaction> CoinTransactions { 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!;
|
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
||||||
|
|
||||||
static AppDbContext()
|
static AppDbContext()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
|
|||||||
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
|
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
|
||||||
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
|
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
|
||||||
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
|
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.ExternalId).HasColumnName("external_id").HasMaxLength(255);
|
||||||
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
|
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
|
||||||
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
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.QualityScore).HasColumnName("quality_score");
|
||||||
builder.Property(r => r.IsInformative).HasColumnName("is_informative");
|
builder.Property(r => r.IsInformative).HasColumnName("is_informative");
|
||||||
builder.Property(r => r.LlmTags).HasColumnName("llm_tags");
|
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.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
builder.Property(r => r.UpdatedAt).HasColumnName("updated_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
Reference in New Issue
Block a user