Compare commits
89 Commits
9e5a72c53a
...
8223697bd3
| Author | SHA1 | Date | |
|---|---|---|---|
| 8223697bd3 | |||
| 3106f0ef61 | |||
| 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,63 @@
|
||||
name: Frontend CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "dev" ]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
pull_request:
|
||||
branches: [ "main", "dev" ]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
|
||||
jobs:
|
||||
build-and-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Audit dependencies
|
||||
if: always()
|
||||
run: pnpm audit --audit-level moderate
|
||||
|
||||
- name: Check formatting
|
||||
if: always()
|
||||
run: pnpm exec prettier --check src/
|
||||
|
||||
- name: Lint with oxlint
|
||||
if: always()
|
||||
run: pnpm exec oxlint .
|
||||
|
||||
- name: Lint with ESLint
|
||||
if: always()
|
||||
run: pnpm exec eslint . --max-warnings=0
|
||||
|
||||
- name: Type check
|
||||
if: always()
|
||||
run: pnpm run type-check
|
||||
|
||||
- name: Build
|
||||
if: always()
|
||||
run: pnpm run build-only
|
||||
@@ -1,17 +1,48 @@
|
||||
name: Create and publish a Docker image
|
||||
name: 🚀 Create and publish a Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main', 'staging']
|
||||
branches: ['main', 'dev']
|
||||
|
||||
env:
|
||||
CONTEXT: ./backend
|
||||
BACKEND_PATH: backend
|
||||
FRONTEND_PATH: frontend
|
||||
SERVER_DOMAIN: ${{ gitea.server_url.replace('https://', '') }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish image
|
||||
name: Detect changes in backend and frontend
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
backend_changed: ${{ steps.backend-changed.outputs.backend }}
|
||||
frontend_changed: ${{ steps.frontend-changed.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Check for backend changes
|
||||
id: backend-changed
|
||||
uses: dorny/paths-filter@v2
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- '${{ env.BACKEND_PATH }}/**'
|
||||
|
||||
- name: Check for frontend changes
|
||||
id: frontend-changed
|
||||
uses: dorny/paths-filter@v2
|
||||
with:
|
||||
filters: |
|
||||
frontend:
|
||||
- '${{ env.FRONTEND_PATH }}/**'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build & publish backend image
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs: [detect-changes]
|
||||
if: ${{ needs.detect-changes.outputs.backend_changed == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -24,17 +55,69 @@ jobs:
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}
|
||||
- name: Build an image from Dockerfile
|
||||
run: |
|
||||
cd ${{ env.CONTEXT }} &&
|
||||
docker build -f UniVerse.Api/Dockerfile -t ${{ steps.meta.outputs.tags }} .
|
||||
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/backend
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ vars.SERVER_DOMAIN }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
- name: Push
|
||||
run: |
|
||||
docker push '${{ steps.meta.outputs.tags }}'
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: ./${{ env.BACKEND_PATH }}
|
||||
file: ./${{ env.BACKEND_PATH }}/UniVerse.Api/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build & publish frontend image
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs: [detect-changes]
|
||||
if: ${{ needs.detect-changes.outputs.frontend_changed == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ vars.SERVER_DOMAIN }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/frontend
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: ./${{ env.FRONTEND_PATH }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend, backend]
|
||||
# always() - костыль для того, чтобы деплой выполнялся даже если один из билдов пропущен
|
||||
if: github.ref == 'refs/heads/dev' && always() && (needs.backend.result == 'success' || needs.frontend.result == 'success')
|
||||
name: Update stack on Portainer
|
||||
steps:
|
||||
- name: Deploy Stage
|
||||
uses: fjogeleit/http-request-action@v1
|
||||
with:
|
||||
url: ${{ secrets.PORTAINER_WEBHOOK_URL }}
|
||||
method: 'POST'
|
||||
ignoreSsl: true
|
||||
timeout: 60000
|
||||
|
||||
@@ -141,6 +141,7 @@ $RECYCLE.BIN/
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
@@ -160,3 +161,4 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
backend/UniVerse.Api/appsettings.Development.json
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
|
||||
|
||||
[Документация API](backend/UniVerse.Api/openapi.json)
|
||||
[Документация бекнда](docs/backend.md)
|
||||
|
||||
## Что внутри
|
||||
|
||||
- Расписание/события и сущности: курсы, лекции, аудитории (locations)
|
||||
@@ -101,9 +104,13 @@ docker run --rm -p 8080:8080 \
|
||||
## Аутентификация
|
||||
|
||||
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
|
||||
- `POST /api/v1/auth/login/microsoft` — заготовка под Microsoft Entra ID (сейчас не реализовано).
|
||||
- `GET /api/v1/auth/login/microsoft` — старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft).
|
||||
- `GET /api/v1/auth/callback/microsoft` — callback, куда Microsoft возвращает `code`.
|
||||
- `POST /api/v1/auth/login/microsoft` — обмен `authorizationCode` на токены (полезно для интеграций/ручных тестов). Тело: `{ "authorizationCode": "...", "redirectUri"?: "..." }`.
|
||||
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
|
||||
|
||||
Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`).
|
||||
|
||||
Большинство методов API защищены `[Authorize]`.
|
||||
|
||||
## Фоновый LLM-анализ отзывов
|
||||
@@ -140,3 +147,19 @@ LLM-ключ задаётся через `Llm:ApiKey`.
|
||||
|
||||
Точные схемы запросов/ответов удобнее смотреть в Swagger.
|
||||
|
||||
## Тестирование
|
||||
|
||||
В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
|
||||
|
||||
- **xUnit** в качестве основного фреймворка для тестирования.
|
||||
- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
|
||||
- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
|
||||
- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
|
||||
|
||||
Запуск тестов:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
dotnet test
|
||||
```
|
||||
|
||||
|
||||
Generated
+1
@@ -0,0 +1 @@
|
||||
UniVerse
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<attachedFolders>
|
||||
<Path>../frontend</Path>
|
||||
</attachedFolders>
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
|
||||
+6
@@ -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,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Domain.Exceptions;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Auth;
|
||||
|
||||
public class AuthServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_InactiveUser_RevokesTokenAndThrowsForbidden()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Id = 1,
|
||||
Email = "blocked@test.local",
|
||||
IsActive = false,
|
||||
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
|
||||
});
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = 1,
|
||||
UserId = 1,
|
||||
Token = "refresh-token",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(1),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
await Assert.ThrowsAsync<ForbiddenException>(() => service.RefreshTokenAsync("refresh-token"));
|
||||
|
||||
var token = await db.RefreshTokens.SingleAsync(t => t.Token == "refresh-token");
|
||||
Assert.NotNull(token.RevokedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUserAsync_InactiveUser_ThrowsForbidden()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Id = 1,
|
||||
Email = "blocked@test.local",
|
||||
IsActive = false,
|
||||
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginWithMicrosoftAsync_LinksScheduleTeacherBySubId()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Id = 10,
|
||||
Email = "modeus-person-1@modeus.local",
|
||||
DisplayName = "Иванов Иван Иванович",
|
||||
MicrosoftId = "sso-sub-1",
|
||||
IsActive = true,
|
||||
Roles = [new UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
|
||||
TeacherProfile = new TeacherProfile { UserId = 10, ModeusId = "person-1" }
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var microsoftAuth = Substitute.For<IMicrosoftAuthClient>();
|
||||
microsoftAuth.ExchangeAuthorizationCodeAsync("code", "http://localhost/callback", Arg.Any<CancellationToken>())
|
||||
.Returns(new MicrosoftTokenResult(BuildIdToken("sso-sub-1", "teacher@sfedu.ru", "Иванов Иван Иванович")));
|
||||
var service = CreateService(db, microsoftAuth);
|
||||
|
||||
var result = await service.LoginWithMicrosoftAsync("code", "http://localhost/callback");
|
||||
|
||||
Assert.Equal(10, result.Response.User.Id);
|
||||
Assert.Equal("teacher@sfedu.ru", result.Response.User.Email);
|
||||
Assert.Contains(UserRole.Teacher, result.Response.User.Roles);
|
||||
Assert.Single(await db.Users.ToListAsync());
|
||||
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
|
||||
Assert.Equal("sso-sub-1", user.MicrosoftId);
|
||||
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"AuthServiceTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private static AuthService CreateService(AppDbContext db, IMicrosoftAuthClient? microsoftAuth = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:Secret"] = "test-secret-test-secret-test-secret-test-secret",
|
||||
["Jwt:Issuer"] = "UniVerse.Tests",
|
||||
["Jwt:Audience"] = "UniVerse.Tests",
|
||||
["Jwt:AccessTokenExpirationMinutes"] = "15",
|
||||
["Jwt:RefreshTokenExpirationDays"] = "30"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var gamification = Substitute.For<IGamificationService>();
|
||||
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
|
||||
|
||||
var notifications = Substitute.For<INotificationService>();
|
||||
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
microsoftAuth ??= Substitute.For<IMicrosoftAuthClient>();
|
||||
return new AuthService(db, config, microsoftAuth, gamification, notifications, NullLogger<AuthService>.Instance);
|
||||
}
|
||||
|
||||
private static string BuildIdToken(string sub, string email, string name)
|
||||
{
|
||||
var token = new JwtSecurityToken(claims:
|
||||
[
|
||||
new Claim(JwtRegisteredClaimNames.Sub, sub),
|
||||
new Claim("preferred_username", email),
|
||||
new Claim("name", name)
|
||||
]);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Net;
|
||||
using UniVerse.Api.Tests.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API.
|
||||
///
|
||||
/// Каждый тестовый случай представляет собой кортеж:
|
||||
/// (description, method, url, requiredRole, forbiddenRoles[])
|
||||
///
|
||||
/// Три типа сценариев для каждой конечной точки:
|
||||
/// A) Анонимный → 401 Unauthorized
|
||||
/// B) Неправильная роль → 403 Forbidden
|
||||
/// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка)
|
||||
/// </summary>
|
||||
public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public EndpointAuthorizationTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Тестовые данные
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Конечные точки, требующие аутентификации (не анонимные).
|
||||
/// Формат: (description, method, url, correctRole, forbiddenRoles[])
|
||||
///
|
||||
/// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли.
|
||||
/// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> AuthenticatedEndpoints()
|
||||
{
|
||||
// ── Auth ─────────────────────────────────────────────────────────────
|
||||
yield return E("auth/logout [AnyAuth]", "POST", "api/v1/auth/logout", "Student");
|
||||
yield return E("auth/me [AnyAuth]", "GET", "api/v1/auth/me", "Student");
|
||||
|
||||
// ── Users — current user ──────────────────────────────────────────────
|
||||
yield return E("users/me GET [AnyAuth]", "GET", "api/v1/users/me", "Student");
|
||||
yield return E("users/me PUT [AnyAuth]", "PUT", "api/v1/users/me", "Student",
|
||||
body: """{"displayName":"Test","avatarUrl":null}""");
|
||||
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
|
||||
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
|
||||
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
|
||||
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
|
||||
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
|
||||
|
||||
// ── Users — Admin only ────────────────────────────────────────────────
|
||||
yield return E("users GET [Admin]", "GET", "api/v1/users", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("users/{id} GET [Admin]", "GET", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("users/{id} PUT [Admin]", "PUT", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"displayName":"Test","avatarUrl":null}""");
|
||||
yield return E("users/{id}/stats [Admin]", "GET", "api/v1/users/1/stats", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("users/{id}/enrollments [Admin]", "GET", "api/v1/users/1/enrollments", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("users/{id}/reviews [Admin]", "GET", "api/v1/users/1/reviews","Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("users/{id}/achievements [Admin]", "GET", "api/v1/users/1/achievements","Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("users/{id}/transactions [Admin]", "GET", "api/v1/users/1/transactions","Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("users/{id}/role PATCH [Admin]", "PATCH", "api/v1/users/1/role", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: "\"Student\"");
|
||||
yield return E("users/{id}/active PATCH [Admin]", "PATCH", "api/v1/users/1/active", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: "true");
|
||||
|
||||
// ── Courses — any auth ────────────────────────────────────────────────
|
||||
yield return E("courses GET [AnyAuth]", "GET", "api/v1/courses", "Student");
|
||||
yield return E("courses/{id} GET [AnyAuth]", "GET", "api/v1/courses/1", "Student");
|
||||
|
||||
// ── Courses — Admin only ──────────────────────────────────────────────
|
||||
yield return E("courses POST [Admin]", "POST", "api/v1/courses", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Course","description":null}""");
|
||||
yield return E("courses/{id} PUT [Admin]", "PUT", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Course","description":null}""");
|
||||
yield return E("courses/{id} DELETE [Admin]", "DELETE", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("courses/{id}/tags POST [Admin]", "POST", "api/v1/courses/1/tags", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: "1");
|
||||
yield return E("courses/{id}/tags/{tagId} DELETE [Admin]","DELETE","api/v1/courses/1/tags/1","Admin",forbidden: ["Student", "Teacher"]);
|
||||
|
||||
// ── Lectures — any auth ───────────────────────────────────────────────
|
||||
yield return E("lectures GET [AnyAuth]", "GET", "api/v1/lectures", "Student");
|
||||
yield return E("lectures/{id} GET [AnyAuth]", "GET", "api/v1/lectures/1", "Student");
|
||||
|
||||
// ── Lectures — Admin only ─────────────────────────────────────────────
|
||||
yield return E("lectures POST [Admin]", "POST", "api/v1/lectures", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"courseId":1,"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
|
||||
yield return E("lectures/{id} DELETE [Admin]", "DELETE", "api/v1/lectures/1", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
|
||||
// ── Lectures — Admin OR Teacher ───────────────────────────────────────
|
||||
yield return E("lectures/{id} PUT [Admin]", "PUT", "api/v1/lectures/1", "Admin", forbidden: ["Student"],
|
||||
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
|
||||
yield return E("lectures/{id} PUT [Teacher]", "PUT", "api/v1/lectures/1", "Teacher", forbidden: ["Student"],
|
||||
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
|
||||
yield return E("lectures/{id}/attendance PATCH [Admin]", "PATCH","api/v1/lectures/1/attendance/2","Admin", forbidden: ["Student"],
|
||||
body: "true");
|
||||
yield return E("lectures/{id}/attendance PATCH [Teacher]","PATCH","api/v1/lectures/1/attendance/2","Teacher",forbidden: ["Student"],
|
||||
body: "true");
|
||||
yield return E("lectures/{id}/enrollments GET [Admin]", "GET","api/v1/lectures/1/enrollments","Admin", forbidden: ["Student"]);
|
||||
yield return E("lectures/{id}/enrollments GET [Teacher]","GET","api/v1/lectures/1/enrollments","Teacher",forbidden: ["Student"]);
|
||||
yield return E("lectures/{id}/reviews GET [Admin]", "GET","api/v1/lectures/1/reviews","Admin", forbidden: ["Student"]);
|
||||
yield return E("lectures/{id}/reviews GET [Teacher]", "GET","api/v1/lectures/1/reviews","Teacher",forbidden: ["Student"]);
|
||||
|
||||
// ── Lectures — Student only ───────────────────────────────────────────
|
||||
yield return E("lectures/{id}/enroll POST [Student]", "POST", "api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
|
||||
yield return E("lectures/{id}/enroll DELETE [Student]", "DELETE","api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
|
||||
|
||||
// ── Reviews — any auth ────────────────────────────────────────────────
|
||||
yield return E("reviews/{id} PUT [AnyAuth]", "PUT", "api/v1/reviews/1", "Student",
|
||||
body: """{"rating":"Like","text":"Updated"}""");
|
||||
yield return E("reviews/{id} DELETE [AnyAuth]", "DELETE", "api/v1/reviews/1", "Student");
|
||||
|
||||
// ── Reviews — Admin OR Teacher ───────────────────────────────────────
|
||||
yield return E("reviews/{id} GET [Admin]", "GET", "api/v1/reviews/1", "Admin", forbidden: ["Student"]);
|
||||
yield return E("reviews/{id} GET [Teacher]", "GET", "api/v1/reviews/1", "Teacher", forbidden: ["Student"]);
|
||||
|
||||
// ── Reviews — Student only ────────────────────────────────────────────
|
||||
yield return E("reviews POST [Student]", "POST", "api/v1/reviews", "Student", forbidden: ["Admin", "Teacher"],
|
||||
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
|
||||
|
||||
// ── Reviews — Admin only ──────────────────────────────────────────────
|
||||
yield return E("reviews GET [Admin]", "GET", "api/v1/reviews", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("reviews/llm-prompt GET [Admin]", "GET", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
|
||||
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
|
||||
|
||||
// ── Tags — any auth ───────────────────────────────────────────────────
|
||||
yield return E("tags GET [AnyAuth]", "GET", "api/v1/tags", "Student");
|
||||
yield return E("tags/{id} GET [AnyAuth]", "GET", "api/v1/tags/1", "Student");
|
||||
yield return E("tags/tree GET [AnyAuth]", "GET", "api/v1/tags/tree", "Student");
|
||||
|
||||
// ── Tags — Admin only ─────────────────────────────────────────────────
|
||||
yield return E("tags POST [Admin]", "POST", "api/v1/tags", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Tag","type":"Topic","parentId":null}""");
|
||||
yield return E("tags/{id} PUT [Admin]", "PUT", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Tag","type":"Topic","parentId":null}""");
|
||||
yield return E("tags/{id} DELETE [Admin]", "DELETE", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
|
||||
// ── Locations — any auth ──────────────────────────────────────────────
|
||||
yield return E("locations GET [AnyAuth]", "GET", "api/v1/locations", "Student");
|
||||
yield return E("locations/{id} GET [AnyAuth]", "GET", "api/v1/locations/1", "Student");
|
||||
|
||||
// ── Locations — Admin only ────────────────────────────────────────────
|
||||
yield return E("locations POST [Admin]", "POST", "api/v1/locations", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
|
||||
yield return E("locations/{id} PUT [Admin]", "PUT", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
|
||||
yield return E("locations/{id} DELETE [Admin]", "DELETE", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
|
||||
// ── Achievements — any auth ───────────────────────────────────────────
|
||||
yield return E("achievements GET [AnyAuth]", "GET", "api/v1/achievements", "Student");
|
||||
yield return E("achievements/{id} GET [AnyAuth]", "GET", "api/v1/achievements/1", "Student");
|
||||
|
||||
// ── Achievements — Admin only ─────────────────────────────────────────
|
||||
yield return E("achievements POST [Admin]", "POST", "api/v1/achievements", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
|
||||
yield return E("achievements/{id} PUT [Admin]", "PUT", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
|
||||
yield return E("achievements/{id} DELETE [Admin]", "DELETE", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
|
||||
// ── Sync — Admin only ─────────────────────────────────────────────────
|
||||
yield return E("sync/schedule POST [Admin]", "POST", "api/v1/sync/schedule", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"specialtyCode":null,"timeMin":null,"timeMax":null,"typeId":null}""");
|
||||
yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
|
||||
yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
|
||||
|
||||
// ── Notifications — any auth ───────────────────────────────────────────
|
||||
yield return E("notifications GET [AnyAuth]", "GET", "api/v1/notifications", "Student");
|
||||
yield return E("notifications/read-all PATCH [AnyAuth]", "PATCH", "api/v1/notifications/read-all", "Student");
|
||||
|
||||
// ── Notifications — Admin only ─────────────────────────────────────────
|
||||
yield return E("notifications/send POST [Admin]", "POST", "api/v1/notifications/send", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: """{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello"}""");
|
||||
yield return E("notifications/schedule POST [Admin]", "POST", "api/v1/notifications/schedule", "Admin", forbidden: ["Student", "Teacher"],
|
||||
body: $$"""{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello","sendAt":"{{DateTimeOffset.UtcNow.AddMinutes(5):O}}"}""");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401.
|
||||
/// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401)
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> AnonymousEndpoints()
|
||||
{
|
||||
// login/microsoft GET перенаправляет на Microsoft — AzureAd настроен в фабрике
|
||||
yield return new object[] { "auth/login/microsoft GET", "GET", "api/v1/auth/login/microsoft" };
|
||||
// callback разрешает анонимный доступ — возвращает 400, если отсутствует параметр code
|
||||
yield return new object[] { "auth/callback/microsoft GET", "GET", "api/v1/auth/callback/microsoft" };
|
||||
// dev login доступен в окружении Development
|
||||
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
|
||||
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
|
||||
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
|
||||
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
|
||||
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Тест: анонимный → 401
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AuthenticatedEndpoints))]
|
||||
public async Task Endpoint_Anonymous_Returns401(
|
||||
string description, string method, string url,
|
||||
string correctRole, string[] forbiddenRoles, string? body)
|
||||
{
|
||||
// Подготовка — без заголовка аутентификации
|
||||
var request = BuildRequest(method, url, body, authHeader: null);
|
||||
|
||||
// Действие
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
// Проверка
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"[{description}] Ожидался ответ 401 Unauthorized для анонимного запроса, получено {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Тест: неправильная роль → 403
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AuthenticatedEndpoints))]
|
||||
public async Task Endpoint_WrongRole_Returns403(
|
||||
string description, string method, string url,
|
||||
string correctRole, string[] forbiddenRoles, string? body)
|
||||
{
|
||||
foreach (var forbidden in forbiddenRoles)
|
||||
{
|
||||
// Подготовка
|
||||
var request = BuildRequest(method, url, body,
|
||||
authHeader: TestJwtFactory.BearerHeader(forbidden));
|
||||
|
||||
// Действие
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
// Проверка
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Forbidden,
|
||||
$"[{description}] Ожидался ответ 403 Forbidden для роли '{forbidden}', получено {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Тест: правильная роль → не 401 и не 403
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AuthenticatedEndpoints))]
|
||||
public async Task Endpoint_CorrectRole_PassesAuthz(
|
||||
string description, string method, string url,
|
||||
string correctRole, string[] forbiddenRoles, string? body)
|
||||
{
|
||||
// Подготовка
|
||||
var request = BuildRequest(method, url, body,
|
||||
authHeader: TestJwtFactory.BearerHeader(correctRole));
|
||||
|
||||
// Действие
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
// Проверка — принимается любой ответ, который НЕ 401/403
|
||||
Assert.True(
|
||||
response.StatusCode != HttpStatusCode.Unauthorized &&
|
||||
response.StatusCode != HttpStatusCode.Forbidden,
|
||||
$"[{description}] Роль '{correctRole}' должна успешно пройти авторизацию, получено {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Тест: анонимные конечные точки не должны возвращать 401
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AnonymousEndpoints))]
|
||||
public async Task AnonymousEndpoint_NoToken_DoesNotReturn401(
|
||||
string description, string method, string url, string? body = null)
|
||||
{
|
||||
var request = BuildRequest(method, url, body, authHeader: null);
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
Assert.True(
|
||||
response.StatusCode != HttpStatusCode.Unauthorized,
|
||||
$"[{description}] Анонимная конечная точка не должна возвращать 401, получено {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Вспомогательные методы
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static HttpRequestMessage BuildRequest(
|
||||
string method, string url, string? body, string? authHeader)
|
||||
{
|
||||
var request = new HttpRequestMessage(new HttpMethod(method), url);
|
||||
|
||||
if (authHeader != null)
|
||||
request.Headers.Add("Authorization", authHeader);
|
||||
|
||||
if (body != null)
|
||||
request.Content = new StringContent(body,
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>Вспомогательный метод для компактного создания массивов объектов [MemberData].</summary>
|
||||
private static object[] E(
|
||||
string description,
|
||||
string method,
|
||||
string url,
|
||||
string correctRole,
|
||||
string[]? forbidden = null,
|
||||
string? body = null)
|
||||
=> [description, method, url, correctRole, forbidden ?? [], body];
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Gamification;
|
||||
|
||||
public class GamificationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
SeedLevelThresholds(db);
|
||||
var service = CreateService(db);
|
||||
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Id = 1,
|
||||
Email = "student@test.local",
|
||||
DisplayName = "Student",
|
||||
AvatarUrl = "avatar.png",
|
||||
Xp = 100,
|
||||
Coins = 510
|
||||
});
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(new Lecture
|
||||
{
|
||||
Id = 1,
|
||||
CourseId = 1,
|
||||
Title = "Future lecture",
|
||||
StartsAt = DateTime.UtcNow.AddDays(1),
|
||||
EndsAt = DateTime.UtcNow.AddDays(1).AddHours(2),
|
||||
IsOpen = true
|
||||
});
|
||||
db.LectureEnrollments.Add(new LectureEnrollment { UserId = 1, LectureId = 1 });
|
||||
db.Reviews.AddRange(
|
||||
new Review { Id = 1, UserId = 1, LectureId = 1, Rating = ReviewRating.Like },
|
||||
new Review { Id = 2, UserId = 1, LectureId = 1, Rating = ReviewRating.Neutral },
|
||||
new Review { Id = 3, UserId = 1, LectureId = 1, Rating = ReviewRating.Dislike });
|
||||
db.CoinTransactions.Add(new CoinTransaction
|
||||
{
|
||||
UserId = 1,
|
||||
Amount = 510,
|
||||
Type = CoinTransactionType.AdminAdjustment,
|
||||
Description = "Initial coins"
|
||||
});
|
||||
db.Achievements.AddRange(
|
||||
Achievement(1001, "First activity", "first_activity:1", 10),
|
||||
Achievement(1002, "Reviews", "reviews_written:3", 20),
|
||||
Achievement(1003, "Active registrations", "active_registrations:1", 30),
|
||||
Achievement(1004, "Coins earned", "coins_earned:500", 40),
|
||||
Achievement(1005, "Level reached", "level_reached:2", 50),
|
||||
Achievement(1006, "Profile completed", "profile_completed:1", 60),
|
||||
Achievement(1007, "Old condition", "reviews_1", 100));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await service.CheckAndAwardAchievementsAsync(1);
|
||||
await service.CheckAndAwardAchievementsAsync(1);
|
||||
|
||||
var user = await db.Users.FindAsync(1);
|
||||
Assert.NotNull(user);
|
||||
Assert.Equal(720, user!.Coins);
|
||||
Assert.Equal(310, user.Xp);
|
||||
Assert.Equal(6, await db.UserAchievements.CountAsync(ua => ua.UserId == 1));
|
||||
Assert.False(await db.UserAchievements.AnyAsync(ua => ua.AchievementId == 1007));
|
||||
Assert.Equal(6, await db.CoinTransactions.CountAsync(ct =>
|
||||
ct.UserId == 1 && ct.Type == CoinTransactionType.AchievementReward));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
SeedLevelThresholds(db);
|
||||
var service = CreateService(db);
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.AddRange(
|
||||
Lecture(1, new DateTime(2025, 12, 29, 10, 0, 0, DateTimeKind.Utc)),
|
||||
Lecture(2, new DateTime(2026, 1, 5, 10, 0, 0, DateTimeKind.Utc)),
|
||||
Lecture(3, new DateTime(2026, 1, 12, 10, 0, 0, DateTimeKind.Utc)));
|
||||
db.LectureEnrollments.AddRange(
|
||||
new LectureEnrollment { UserId = 1, LectureId = 1, Attended = true },
|
||||
new LectureEnrollment { UserId = 1, LectureId = 2, Attended = true },
|
||||
new LectureEnrollment { UserId = 1, LectureId = 3, Attended = true });
|
||||
db.Achievements.Add(Achievement(1001, "Streak", "attendance_streak_weeks:3", 10));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await service.CheckAndAwardAchievementsAsync(1);
|
||||
|
||||
Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(99, 1)]
|
||||
[InlineData(100, 2)]
|
||||
[InlineData(299, 2)]
|
||||
[InlineData(300, 3)]
|
||||
public async Task CalculateLevelAsync_UsesDatabaseThresholds(int xp, int expectedLevel)
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
SeedLevelThresholds(db);
|
||||
var service = CreateService(db);
|
||||
|
||||
var level = await service.CalculateLevelAsync(xp);
|
||||
|
||||
Assert.Equal(expectedLevel, level);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(120, 100, 300)]
|
||||
[InlineData(350, 300, null)]
|
||||
public async Task GetLevelProgressAsync_ReturnsCurrentAndNextThresholds(int xp, int currentLevelXp, int? nextLevelXp)
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
SeedLevelThresholds(db);
|
||||
var service = CreateService(db);
|
||||
|
||||
var progress = await service.GetLevelProgressAsync(xp);
|
||||
|
||||
Assert.Equal(currentLevelXp, progress.CurrentLevelXp);
|
||||
Assert.Equal(nextLevelXp, progress.NextLevelXp);
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private static GamificationService CreateService(AppDbContext db)
|
||||
{
|
||||
var notifications = Substitute.For<INotificationService>();
|
||||
notifications.CreateUserNotificationAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
|
||||
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
return new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
|
||||
}
|
||||
|
||||
private static void SeedLevelThresholds(AppDbContext db)
|
||||
{
|
||||
db.LevelThresholds.AddRange(
|
||||
new LevelThreshold { Level = 1, RequiredXp = 0 },
|
||||
new LevelThreshold { Level = 2, RequiredXp = 100 },
|
||||
new LevelThreshold { Level = 3, RequiredXp = 300 });
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static Achievement Achievement(int id, string name, string condition, int coinReward) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Condition = condition,
|
||||
CoinReward = coinReward
|
||||
};
|
||||
|
||||
private static Lecture Lecture(int id, DateTime startsAt) => new()
|
||||
{
|
||||
Id = id,
|
||||
CourseId = 1,
|
||||
Title = $"Lecture {id}",
|
||||
StartsAt = startsAt,
|
||||
EndsAt = startsAt.AddHours(2)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Achievements;
|
||||
using UniVerse.Application.DTOs.Auth;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.DTOs.Courses;
|
||||
using UniVerse.Application.DTOs.Gamification;
|
||||
using UniVerse.Application.DTOs.Lectures;
|
||||
using UniVerse.Application.DTOs.Locations;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.DTOs.Reviews;
|
||||
using UniVerse.Application.DTOs.Sync;
|
||||
using UniVerse.Application.DTOs.Tags;
|
||||
using UniVerse.Application.DTOs.Users;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
|
||||
namespace UniVerse.Api.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// WebApplicationFactory для интеграционных тестов.
|
||||
/// Заменяет Npgsql DbContext на InMemory, создает заглушки для всех интерфейсов внешних сервисов
|
||||
/// и отключает фоновую службу LLM, чтобы тестам не требовалась реальная инфраструктура.
|
||||
/// </summary>
|
||||
public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
// Используем Development, чтобы были включены Swagger и конечная точка DevLogin
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
// Внедряем настройки тестового JWT — должны совпадать с константами TestJwtFactory
|
||||
var testSettings = new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:Secret"] = TestJwtFactory.Secret,
|
||||
["Jwt:Issuer"] = TestJwtFactory.Issuer,
|
||||
["Jwt:Audience"] = TestJwtFactory.Audience,
|
||||
// Отключаем оркестрацию Aspire
|
||||
["Aspire:Enabled"] = "false",
|
||||
// Фиктивные значения Azure AD (маршруты имеют атрибут [AllowAnonymous] или тестируются отдельно)
|
||||
["AzureAd:TenantId"] = "test-tenant",
|
||||
["AzureAd:ClientId"] = "test-client",
|
||||
// Фиктивные значения LLM / Modeus (клиенты заменяются ниже)
|
||||
["Llm:BaseUrl"] = "http://localhost:9999/",
|
||||
["ModeusApi:BaseUrl"] = "http://localhost:9998/",
|
||||
};
|
||||
config.AddInMemoryCollection(testSettings);
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// ── 1. Заменяем Npgsql DbContext на InMemory ──────────────────────────
|
||||
services.RemoveAll<DbContextOptions<AppDbContext>>();
|
||||
services.RemoveAll<AppDbContext>();
|
||||
|
||||
// Удаляем все регистрации, связанные с DbContext, которые добавил хост
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
// Находим и удаляем все дескрипторы настроек DbContext
|
||||
var dbContextDescriptors = services
|
||||
.Where(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)
|
||||
|| d.ImplementationType == typeof(AppDbContext))
|
||||
.ToList();
|
||||
foreach (var d in dbContextDescriptors) services.Remove(d);
|
||||
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
|
||||
|
||||
// ── 2. Отключаем фоновые службы ────────────────────────────────────
|
||||
// Удаляем все регистрации IHostedService, чтобы предотвратить запуск фоновой задачи LLM
|
||||
var hostedServices = services
|
||||
.Where(d => d.ServiceType == typeof(IHostedService))
|
||||
.ToList();
|
||||
foreach (var d in hostedServices) services.Remove(d);
|
||||
|
||||
// ── 3. Создаем заглушки для всех интерфейсов Application сервисов ─────────
|
||||
ReplaceWithSubstitute<IAuthService>(services, CreateAuthServiceStub());
|
||||
ReplaceWithSubstitute<IUserService>(services, CreateUserServiceStub());
|
||||
ReplaceWithSubstitute<ILectureService>(services, CreateLectureServiceStub());
|
||||
ReplaceWithSubstitute<IReviewService>(services, CreateReviewServiceStub());
|
||||
ReplaceWithSubstitute<IReviewPromptService>(services, CreateReviewPromptServiceStub());
|
||||
ReplaceWithSubstitute<ICourseService>(services, CreateCourseServiceStub());
|
||||
ReplaceWithSubstitute<ITagService>(services, CreateTagServiceStub());
|
||||
ReplaceWithSubstitute<ILocationService>(services, CreateLocationServiceStub());
|
||||
ReplaceWithSubstitute<IAchievementService>(services, CreateAchievementServiceStub());
|
||||
ReplaceWithSubstitute<IGamificationService>(services, CreateGamificationServiceStub());
|
||||
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
|
||||
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
|
||||
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
|
||||
ReplaceWithSubstitute<IMicrosoftAuthClient>(services, Substitute.For<IMicrosoftAuthClient>());
|
||||
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
|
||||
});
|
||||
}
|
||||
|
||||
private static void ReplaceWithSubstitute<TService>(IServiceCollection services, TService instance)
|
||||
where TService : class
|
||||
{
|
||||
services.RemoveAll<TService>();
|
||||
services.AddScoped<TService>(_ => instance);
|
||||
}
|
||||
|
||||
// ── Фабрики заглушек ────────────────────────────────────────────────────────────
|
||||
|
||||
private static IAuthService CreateAuthServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<IAuthService>();
|
||||
var authResult = new AuthResult(
|
||||
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
|
||||
new UserAuthDto(1, "test@test.com", "Test User", [UserRole.Student])),
|
||||
"refresh_token");
|
||||
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
|
||||
.Returns(authResult);
|
||||
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<IReadOnlyCollection<UserRole>>(), Arg.Any<string?>())
|
||||
.Returns(authResult);
|
||||
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
||||
stub.GetCurrentUserAsync(Arg.Any<int>())
|
||||
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static INotificationService CreateNotificationServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<INotificationService>();
|
||||
stub.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
stub.ScheduleAsync(Arg.Any<ScheduleNotificationRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5)));
|
||||
stub.GetUserNotificationsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(PagedResult<UserNotificationDto>.Create([], 0, 1, 20));
|
||||
stub.MarkAllReadAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
stub.CreateUserNotificationAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new UserNotificationDto(1, "achievement", "Title", "Body", false, DateTime.UtcNow));
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static IUserService CreateUserServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<IUserService>();
|
||||
var userDto = new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow);
|
||||
var pagedUsers = PagedResult<UserDto>.Create([userDto], 1, 1, 20);
|
||||
var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null,
|
||||
"Title", null, LectureFormat.Offline,
|
||||
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
|
||||
true, 30, 0, null, DateTime.UtcNow, true);
|
||||
var pagedLectures = PagedResult<LectureDto>.Create([lectureDto], 1, 1, 20);
|
||||
|
||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
|
||||
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
|
||||
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
0,
|
||||
3,
|
||||
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
|
||||
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
|
||||
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
|
||||
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
|
||||
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static ILectureService CreateLectureServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<ILectureService>();
|
||||
var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null,
|
||||
"Title", null, LectureFormat.Offline,
|
||||
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
|
||||
true, 30, 0, null, DateTime.UtcNow);
|
||||
var detailDto = new LectureDetailDto(1, 1, "Course", null, null, null, null,
|
||||
"Title", null, LectureFormat.Offline,
|
||||
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
|
||||
true, 30, 0, null, DateTime.UtcNow, false);
|
||||
var pagedLectures = PagedResult<LectureDto>.Create([lectureDto], 1, 1, 20);
|
||||
var pagedEnrollments = PagedResult<EnrollmentDto>.Create([], 0, 1, 20);
|
||||
|
||||
stub.GetAllAsync(Arg.Any<LectureFilterRequest>(), Arg.Any<int?>()).Returns(pagedLectures);
|
||||
stub.GetByIdAsync(Arg.Any<int>(), Arg.Any<int?>()).Returns(detailDto);
|
||||
stub.CreateAsync(Arg.Any<CreateLectureRequest>()).Returns(lectureDto);
|
||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(lectureDto);
|
||||
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
stub.EnrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
stub.UnenrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(pagedEnrollments);
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static IReviewService CreateReviewServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<IReviewService>();
|
||||
var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User",
|
||||
ReviewRating.Like, "Great!", ReviewLlmStatus.Pending,
|
||||
null, null, null, null, null, DateTime.UtcNow);
|
||||
var pagedReviews = PagedResult<ReviewDto>.Create([reviewDto], 1, 1, 20);
|
||||
|
||||
stub.CreateAsync(Arg.Any<int>(), Arg.Any<CreateReviewRequest>()).Returns(reviewDto);
|
||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(reviewDto);
|
||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<UpdateReviewRequest>()).Returns(reviewDto);
|
||||
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int?>(), Arg.Any<bool>()).Returns(pagedReviews);
|
||||
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
||||
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
|
||||
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static IReviewPromptService CreateReviewPromptServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<IReviewPromptService>();
|
||||
var promptDto = new ReviewPromptDto(
|
||||
"Analyze {lectureContext}. Review: {reviewText}",
|
||||
DateTime.UtcNow);
|
||||
|
||||
stub.GetAsync().Returns(promptDto);
|
||||
stub.UpdateAsync(Arg.Any<UpdateReviewPromptRequest>()).Returns(callInfo =>
|
||||
new ReviewPromptDto(callInfo.Arg<UpdateReviewPromptRequest>().Prompt, DateTime.UtcNow));
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static ICourseService CreateCourseServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<ICourseService>();
|
||||
var courseDto = new CourseDto(1, "Course", null, false, [], DateTime.UtcNow);
|
||||
var paged = PagedResult<CourseDto>.Create([courseDto], 1, 1, 20);
|
||||
|
||||
stub.GetAllAsync(Arg.Any<CourseFilterRequest>()).Returns(paged);
|
||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(courseDto);
|
||||
stub.CreateAsync(Arg.Any<CreateCourseRequest>()).Returns(courseDto);
|
||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateCourseRequest>()).Returns(courseDto);
|
||||
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
stub.AddTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
stub.RemoveTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static ITagService CreateTagServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<ITagService>();
|
||||
var tagDto = new TagDto(1, "Tag", TagType.Topic, null, DateTime.UtcNow);
|
||||
|
||||
stub.GetAllAsync(Arg.Any<TagType?>(), Arg.Any<int?>()).Returns([tagDto]);
|
||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(tagDto);
|
||||
stub.CreateAsync(Arg.Any<CreateTagRequest>()).Returns(tagDto);
|
||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateTagRequest>()).Returns(tagDto);
|
||||
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
stub.GetTreeAsync().Returns(new List<TagTreeDto>());
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static ILocationService CreateLocationServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<ILocationService>();
|
||||
var locationDto = new LocationDto(1, "Room 101", null, null, null, DateTime.UtcNow);
|
||||
|
||||
stub.GetAllAsync().Returns([locationDto]);
|
||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(locationDto);
|
||||
stub.CreateAsync(Arg.Any<CreateLocationRequest>()).Returns(locationDto);
|
||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLocationRequest>()).Returns(locationDto);
|
||||
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static IAchievementService CreateAchievementServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<IAchievementService>();
|
||||
var achievementDto = new AchievementDto(1, "First Review", null, null, 10, 5, null, DateTime.UtcNow);
|
||||
|
||||
stub.GetAllAsync().Returns([achievementDto]);
|
||||
stub.GetByIdAsync(Arg.Any<int>()).Returns(achievementDto);
|
||||
stub.CreateAsync(Arg.Any<CreateAchievementRequest>()).Returns(achievementDto);
|
||||
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateAchievementRequest>()).Returns(achievementDto);
|
||||
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static IGamificationService CreateGamificationServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<IGamificationService>();
|
||||
var paged = PagedResult<CoinTransactionDto>.Create([], 0, 1, 20);
|
||||
|
||||
stub.GetUserAchievementsAsync(Arg.Any<int>()).Returns(new List<UserAchievementDto>());
|
||||
stub.GetTransactionsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(paged);
|
||||
stub.AwardCoinsAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CoinTransactionType>(),
|
||||
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>()).Returns(Task.CompletedTask);
|
||||
stub.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
stub.CalculateLevelAsync(Arg.Any<int>()).Returns(Task.FromResult(1));
|
||||
stub.GetLevelProgressAsync(Arg.Any<int>()).Returns(Task.FromResult(new LevelProgressDto(0, 100)));
|
||||
return stub;
|
||||
}
|
||||
|
||||
private static IScheduleSyncService CreateSyncServiceStub()
|
||||
{
|
||||
var stub = Substitute.For<IScheduleSyncService>();
|
||||
var syncResult = new SyncResultDto(0, 0, 0, null);
|
||||
var syncStatus = new SyncStatusDto(null, "idle", null);
|
||||
|
||||
stub.SyncScheduleAsync(Arg.Any<SyncScheduleRequest>()).Returns(syncResult);
|
||||
stub.SyncRoomsAsync().Returns(syncResult);
|
||||
stub.SearchEmployeesAsync(Arg.Any<string>()).Returns(new List<EmployeeDto>());
|
||||
stub.GetLastSyncStatusAsync().Returns(syncStatus);
|
||||
return stub;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace UniVerse.Api.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Генерирует подписанные JWT токены для использования в интеграционных тестах.
|
||||
/// Использует те же секрет/издателя/аудиторию (secret/issuer/audience), которые внедряет ApiWebApplicationFactory.
|
||||
/// </summary>
|
||||
public static class TestJwtFactory
|
||||
{
|
||||
public const string Secret = "test-super-secret-key-32-chars!!";
|
||||
public const string Issuer = "UniVerse-Test";
|
||||
public const string Audience = "UniVerse-Test";
|
||||
|
||||
/// <summary>Создает валидную строку токена JWT (bearer) для заданной роли и идентификатора пользователя.</summary>
|
||||
public static string Generate(string role, int userId = 1)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim(ClaimTypes.Role, role),
|
||||
new Claim("sub", userId.ToString()),
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddHours(1),
|
||||
signingCredentials: creds);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>Создает значение заголовка Authorization: "Bearer <token>".</summary>
|
||||
public static string BearerHeader(string role, int userId = 1)
|
||||
=> $"Bearer {Generate(role, userId)}";
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Lectures;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
using UniVerse.Domain.Exceptions;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Lectures;
|
||||
|
||||
public class LectureServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAllAsync_MarksLecturesEnrolledByCurrentUser()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
|
||||
var startsAt = DateTime.UtcNow.AddDays(1);
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.AddRange(
|
||||
Lecture(1, startsAt),
|
||||
Lecture(2, startsAt.AddDays(1)));
|
||||
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await service.GetAllAsync(new LectureFilterRequest(null, null, null, null, null, null, null, null), 1);
|
||||
|
||||
Assert.True(result.Items.Single(item => item.Id == 1).IsEnrolled);
|
||||
Assert.False(result.Items.Single(item => item.Id == 2).IsEnrolled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollAsync_SchedulesLectureReminders()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var scheduler = Substitute.For<INotificationScheduler>();
|
||||
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
|
||||
var startsAt = DateTime.UtcNow.AddHours(4);
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(1, startsAt));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await service.EnrollAsync(1, 1);
|
||||
|
||||
await scheduler.Received(1).ScheduleAsync(
|
||||
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 3 часа")),
|
||||
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-3))),
|
||||
"lecture-1-user-1-starts-in-3-hours",
|
||||
Arg.Any<CancellationToken>());
|
||||
await scheduler.Received(1).ScheduleAsync(
|
||||
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 1 час")),
|
||||
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-1))),
|
||||
"lecture-1-user-1-starts-in-1-hour",
|
||||
Arg.Any<CancellationToken>());
|
||||
await scheduler.Received(1).ScheduleAsync(
|
||||
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("Оцените")),
|
||||
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(2))),
|
||||
"lecture-1-user-1-ended",
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollAsync_SkipsPastLectureReminders()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var scheduler = Substitute.For<INotificationScheduler>();
|
||||
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
|
||||
var startsAt = DateTime.UtcNow.AddMinutes(90);
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(1, startsAt));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await service.EnrollAsync(1, 1);
|
||||
|
||||
await scheduler.DidNotReceive().ScheduleAsync(
|
||||
Arg.Any<NotificationMessage>(),
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
"lecture-1-user-1-starts-in-3-hours",
|
||||
Arg.Any<CancellationToken>());
|
||||
await scheduler.Received(2).ScheduleAsync(
|
||||
Arg.Any<NotificationMessage>(),
|
||||
Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 3)]
|
||||
[InlineData(2, 3)]
|
||||
[InlineData(3, 5)]
|
||||
[InlineData(4, 7)]
|
||||
[InlineData(5, 7)]
|
||||
public async Task EnrollAsync_ThrowsWhenActiveEnrollmentLimitReached(int level, int activeEnrollments)
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var gamification = Substitute.For<IGamificationService>();
|
||||
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(level);
|
||||
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
|
||||
var startsAt = DateTime.UtcNow.AddDays(1);
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(100, startsAt.AddDays(100)));
|
||||
for (var i = 1; i <= activeEnrollments; i++)
|
||||
{
|
||||
db.Lectures.Add(Lecture(i, startsAt.AddDays(i)));
|
||||
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var gamification = Substitute.For<IGamificationService>();
|
||||
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
|
||||
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(100, now.AddDays(1)));
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
|
||||
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var gamification = Substitute.For<IGamificationService>();
|
||||
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
|
||||
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(100, now.AddDays(1)));
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
|
||||
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1, Attended = true });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await service.EnrollAsync(100, 1);
|
||||
|
||||
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnenrollAsync_CancelsLectureReminders()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var scheduler = Substitute.For<INotificationScheduler>();
|
||||
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
|
||||
var startsAt = DateTime.UtcNow.AddHours(4);
|
||||
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(Lecture(1, startsAt));
|
||||
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await service.UnenrollAsync(1, 1);
|
||||
|
||||
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-3-hours", Arg.Any<CancellationToken>());
|
||||
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-1-hour", Arg.Any<CancellationToken>());
|
||||
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_TeacherCannotUpdateAnotherTeachersLecture()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
|
||||
lecture.TeacherId = 2;
|
||||
db.Lectures.Add(lecture);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateLectureRequest(null, null, "Updated", null, Domain.Enums.LectureFormat.Offline,
|
||||
DateTime.UtcNow.AddDays(1), DateTime.UtcNow.AddDays(1).AddHours(2), true, 30, null);
|
||||
|
||||
await Assert.ThrowsAsync<ForbiddenException>(() => service.UpdateAsync(1, request, currentUserId: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEnrollmentsAsync_AdminCanReadAnyLecture()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
|
||||
lecture.TeacherId = 2;
|
||||
db.Lectures.Add(lecture);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await service.GetEnrollmentsAsync(1, new UniVerse.Application.DTOs.Common.PaginationRequest(), currentUserId: 1, isAdmin: true);
|
||||
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"LectureServiceTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private static Lecture Lecture(int id, DateTime startsAt) => new()
|
||||
{
|
||||
Id = id,
|
||||
CourseId = 1,
|
||||
Title = $"Lecture {id}",
|
||||
StartsAt = startsAt,
|
||||
EndsAt = startsAt.AddHours(2),
|
||||
IsOpen = true,
|
||||
MaxEnrollments = 30
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Reviews;
|
||||
|
||||
public class LlmAnalysisServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AnalyzeReviewAsync_SavesParsedAnalysisResult()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
await SeedPendingReviewAsync(db);
|
||||
var llm = Substitute.For<ILlmClient>();
|
||||
llm.AnalyzeReviewAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(new LlmReviewAnalysis(
|
||||
0.76,
|
||||
"Положительный",
|
||||
["lecture structure", "practical examples"],
|
||||
true,
|
||||
"{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}"));
|
||||
var gamification = Substitute.For<IGamificationService>();
|
||||
gamification.AwardCoinsAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<CoinTransactionType>(),
|
||||
Arg.Any<int?>(),
|
||||
Arg.Any<int?>(),
|
||||
Arg.Any<string?>())
|
||||
.Returns(Task.CompletedTask);
|
||||
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
var service = new LlmAnalysisService(db, llm, gamification, NullLogger<LlmAnalysisService>.Instance);
|
||||
|
||||
await service.AnalyzeReviewAsync(1);
|
||||
|
||||
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
|
||||
Assert.Equal(ReviewLlmStatus.Analyzed, review.LlmStatus);
|
||||
Assert.Equal(ReviewSentiment.Positive, review.Sentiment);
|
||||
Assert.Equal(0.76, review.QualityScore);
|
||||
Assert.True(review.IsInformative);
|
||||
Assert.Equal(["lecture structure", "practical examples"], review.LlmTags!);
|
||||
Assert.Equal("{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}", review.LlmRawOutput);
|
||||
await gamification.Received(1).AwardCoinsAsync(
|
||||
1,
|
||||
10,
|
||||
CoinTransactionType.ReviewReward,
|
||||
1,
|
||||
null,
|
||||
"Informative review reward");
|
||||
}
|
||||
|
||||
private static async Task SeedPendingReviewAsync(AppDbContext db)
|
||||
{
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(new Lecture
|
||||
{
|
||||
Id = 1,
|
||||
CourseId = 1,
|
||||
Title = "Lecture",
|
||||
StartsAt = DateTime.UtcNow.AddDays(-1),
|
||||
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
|
||||
IsOpen = true,
|
||||
MaxEnrollments = 30
|
||||
});
|
||||
db.Reviews.Add(new Review
|
||||
{
|
||||
Id = 1,
|
||||
LectureId = 1,
|
||||
UserId = 1,
|
||||
Rating = ReviewRating.Like,
|
||||
Text = "Useful review",
|
||||
LlmStatus = ReviewLlmStatus.Pending
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"LlmAnalysisServiceTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using UniVerse.Api.BackgroundServices;
|
||||
using UniVerse.Api.Options;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Reviews;
|
||||
|
||||
public class ReviewAnalysisWorkerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task Worker_DoesNotExceedConfiguredConcurrency(int maxConcurrentProcessing)
|
||||
{
|
||||
var queue = new ReviewAnalysisQueue();
|
||||
var analysisService = new RecordingLlmAnalysisService();
|
||||
await using var provider = CreateServiceProvider(analysisService);
|
||||
var worker = new ReviewAnalysisWorker(
|
||||
provider,
|
||||
queue,
|
||||
Microsoft.Extensions.Options.Options.Create(
|
||||
new ReviewAnalysisOptions { MaxConcurrentProcessing = maxConcurrentProcessing }),
|
||||
NullLogger<ReviewAnalysisWorker>.Instance);
|
||||
|
||||
for (var reviewId = 1; reviewId <= 6; reviewId++)
|
||||
await queue.EnqueueAsync(reviewId);
|
||||
|
||||
analysisService.ExpectProcessed(6);
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
await analysisService.WaitForProcessedAsync();
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(
|
||||
analysisService.MaxRunning <= maxConcurrentProcessing,
|
||||
$"Expected at most {maxConcurrentProcessing} concurrent analyses, got {analysisService.MaxRunning}.");
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateServiceProvider(ILlmAnalysisService analysisService)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"ReviewAnalysisWorkerTests_{Guid.NewGuid()}"));
|
||||
services.AddScoped(_ => analysisService);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private sealed class RecordingLlmAnalysisService : ILlmAnalysisService
|
||||
{
|
||||
private readonly TaskCompletionSource _processedAll = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int _expectedCount;
|
||||
private int _processedCount;
|
||||
private int _running;
|
||||
private int _maxRunning;
|
||||
|
||||
public int MaxRunning => _maxRunning;
|
||||
|
||||
public void ExpectProcessed(int expectedCount)
|
||||
{
|
||||
Volatile.Write(ref _expectedCount, expectedCount);
|
||||
}
|
||||
|
||||
public async Task AnalyzeReviewAsync(int reviewId)
|
||||
{
|
||||
var running = Interlocked.Increment(ref _running);
|
||||
UpdateMaxRunning(running);
|
||||
|
||||
await Task.Delay(50);
|
||||
|
||||
Interlocked.Decrement(ref _running);
|
||||
if (Interlocked.Increment(ref _processedCount) >= Volatile.Read(ref _expectedCount))
|
||||
_processedAll.TrySetResult();
|
||||
}
|
||||
|
||||
public async Task WaitForProcessedAsync()
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
using var registration = timeout.Token.Register(() => _processedAll.TrySetCanceled(timeout.Token));
|
||||
await _processedAll.Task;
|
||||
}
|
||||
|
||||
private void UpdateMaxRunning(int running)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = Volatile.Read(ref _maxRunning);
|
||||
if (running <= current) return;
|
||||
if (Interlocked.CompareExchange(ref _maxRunning, running, current) == current) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Reviews;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Application.Prompts;
|
||||
using UniVerse.Domain.Exceptions;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.ExternalServices;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Reviews;
|
||||
|
||||
public class ReviewPromptServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsDefaultPrompt_WhenSettingDoesNotExist()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = new ReviewPromptService(db);
|
||||
|
||||
var result = await service.GetAsync();
|
||||
|
||||
Assert.Equal(ReviewPromptTemplate.Default, result.Prompt);
|
||||
Assert.Null(result.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_UpsertsSingletonPrompt()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = new ReviewPromptService(db);
|
||||
|
||||
await service.UpdateAsync(new UpdateReviewPromptRequest("First {lectureContext} {reviewText}"));
|
||||
var result = await service.UpdateAsync(new UpdateReviewPromptRequest("Second {lectureContext} {reviewText}"));
|
||||
|
||||
Assert.Equal("Second {lectureContext} {reviewText}", result.Prompt);
|
||||
Assert.NotNull(result.UpdatedAt);
|
||||
Assert.Equal(1, await db.ReviewPromptSettings.CountAsync());
|
||||
Assert.Equal("Second {lectureContext} {reviewText}", (await service.GetAsync()).Prompt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Prompt without placeholders")]
|
||||
[InlineData("Only lecture {lectureContext}")]
|
||||
[InlineData("Only review {reviewText}")]
|
||||
public async Task UpdateAsync_RejectsInvalidPrompt(string prompt)
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = new ReviewPromptService(db);
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
service.UpdateAsync(new UpdateReviewPromptRequest(prompt)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeReviewAsync_RendersCustomPrompt()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://llm.test/")
|
||||
};
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Llm:Model"] = "test-model",
|
||||
["Llm:ApiKey"] = "test-key"
|
||||
})
|
||||
.Build();
|
||||
var promptService = Substitute.For<IReviewPromptService>();
|
||||
promptService.GetAsync().Returns(new ReviewPromptDto(
|
||||
"Custom prompt. Context: {lectureContext}. Text: {reviewText}",
|
||||
DateTime.UtcNow));
|
||||
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
|
||||
|
||||
await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
|
||||
|
||||
Assert.NotNull(handler.RequestBody);
|
||||
using var requestJson = JsonDocument.Parse(handler.RequestBody!);
|
||||
var content = requestJson.RootElement
|
||||
.GetProperty("messages")[0]
|
||||
.GetProperty("content")
|
||||
.GetString();
|
||||
|
||||
Assert.Contains("Custom prompt", content);
|
||||
Assert.Contains("Lecture: Algebra", content);
|
||||
Assert.Contains("Very useful review", content);
|
||||
Assert.DoesNotContain(ReviewPromptTemplate.LectureContextPlaceholder, content);
|
||||
Assert.DoesNotContain(ReviewPromptTemplate.ReviewTextPlaceholder, content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeReviewAsync_ParsesSnakeCaseJsonFromFencedResponse()
|
||||
{
|
||||
var handler = new CapturingHandler("""
|
||||
```json
|
||||
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
|
||||
```
|
||||
""");
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://llm.test/")
|
||||
};
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Llm:Model"] = "test-model",
|
||||
["Llm:ApiKey"] = "test-key"
|
||||
})
|
||||
.Build();
|
||||
var promptService = Substitute.For<IReviewPromptService>();
|
||||
promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null));
|
||||
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
|
||||
|
||||
var result = await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
|
||||
|
||||
Assert.Equal(0.82, result.QualityScore);
|
||||
Assert.Equal("Положительный", result.Sentiment);
|
||||
Assert.Equal(["lecture structure", "practical examples"], result.Tags);
|
||||
Assert.True(result.IsInformative);
|
||||
Assert.Equal("""
|
||||
```json
|
||||
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
|
||||
```
|
||||
""", result.RawOutput);
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private sealed class CapturingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string _analysisContent;
|
||||
|
||||
public CapturingHandler(string? analysisContent = null)
|
||||
{
|
||||
_analysisContent = analysisContent ??
|
||||
"{\"quality_score\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"is_informative\":true}";
|
||||
}
|
||||
|
||||
public string? RequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
var responsePayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
choices = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
message = new
|
||||
{
|
||||
content = _analysisContent
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responsePayload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Reviews;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Domain.Exceptions;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Reviews;
|
||||
|
||||
public class ReviewServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_EnqueuesReviewAnalysis()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var queue = Substitute.For<IReviewAnalysisQueue>();
|
||||
var service = CreateService(db, queue);
|
||||
await SeedLectureAsync(db);
|
||||
|
||||
var result = await service.CreateAsync(1, new CreateReviewRequest(1, ReviewRating.Like, "Great lecture"));
|
||||
|
||||
await queue.Received(1).EnqueueAsync(result.Id, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ResetsAnalysisAndEnqueuesReview()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var queue = Substitute.For<IReviewAnalysisQueue>();
|
||||
var service = CreateService(db, queue);
|
||||
await SeedAnalyzedReviewAsync(db);
|
||||
|
||||
await service.UpdateAsync(1, 1, new UpdateReviewRequest(ReviewRating.Neutral, "Updated text"));
|
||||
|
||||
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
|
||||
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
|
||||
Assert.Null(review.Sentiment);
|
||||
Assert.Null(review.QualityScore);
|
||||
Assert.Null(review.IsInformative);
|
||||
Assert.Null(review.LlmTags);
|
||||
Assert.Null(review.LlmRawOutput);
|
||||
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReanalyzeAsync_ResetsAnalysisAndEnqueuesReview()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var queue = Substitute.For<IReviewAnalysisQueue>();
|
||||
var service = CreateService(db, queue);
|
||||
await SeedAnalyzedReviewAsync(db);
|
||||
|
||||
await service.ReanalyzeAsync(1);
|
||||
|
||||
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
|
||||
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
|
||||
Assert.Null(review.Sentiment);
|
||||
Assert.Null(review.QualityScore);
|
||||
Assert.Null(review.IsInformative);
|
||||
Assert.Null(review.LlmTags);
|
||||
Assert.Null(review.LlmRawOutput);
|
||||
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByLectureAsync_TeacherCannotReadAnotherTeachersReviews()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
|
||||
await SeedAnalyzedReviewAsync(db, teacherId: 2);
|
||||
|
||||
await Assert.ThrowsAsync<ForbiddenException>(() =>
|
||||
service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByLectureAsync_AdminCanReadAnyLectureReviews()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
|
||||
await SeedAnalyzedReviewAsync(db, teacherId: 2);
|
||||
|
||||
var result = await service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1, isAdmin: true);
|
||||
|
||||
Assert.Single(result.Items);
|
||||
}
|
||||
|
||||
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
|
||||
{
|
||||
var gamification = Substitute.For<IGamificationService>();
|
||||
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||
return new ReviewService(db, gamification, queue);
|
||||
}
|
||||
|
||||
private static async Task SeedLectureAsync(AppDbContext db, int? teacherId = null)
|
||||
{
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.Add(new Lecture
|
||||
{
|
||||
Id = 1,
|
||||
CourseId = 1,
|
||||
TeacherId = teacherId,
|
||||
Title = "Lecture",
|
||||
StartsAt = DateTime.UtcNow.AddDays(-1),
|
||||
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
|
||||
IsOpen = true,
|
||||
MaxEnrollments = 30
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task SeedAnalyzedReviewAsync(AppDbContext db, int? teacherId = null)
|
||||
{
|
||||
await SeedLectureAsync(db, teacherId);
|
||||
db.Reviews.Add(new Review
|
||||
{
|
||||
Id = 1,
|
||||
LectureId = 1,
|
||||
UserId = 1,
|
||||
Rating = ReviewRating.Like,
|
||||
Text = "Original text",
|
||||
LlmStatus = ReviewLlmStatus.Analyzed,
|
||||
Sentiment = ReviewSentiment.Positive,
|
||||
QualityScore = 0.9,
|
||||
IsInformative = true,
|
||||
LlmTags = ["clear"],
|
||||
LlmRawOutput = "{\"quality_score\":0.9}"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"ReviewServiceTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using UniVerse.Api.Tests.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Swagger;
|
||||
|
||||
public class SwaggerDocumentTests : IClassFixture<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SwaggerDocumentTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwaggerJson_IsGenerated()
|
||||
{
|
||||
var response = await _client.GetAsync("api/docs/v1/swagger.json");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal("UniVerse API", root.GetProperty("info").GetProperty("title").GetString());
|
||||
Assert.True(root.GetProperty("components").GetProperty("securitySchemes").TryGetProperty("Bearer", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwaggerJson_DocumentsSecurityOnlyForAuthorizedEndpoints()
|
||||
{
|
||||
using var document = JsonDocument.Parse(await _client.GetStringAsync("api/docs/v1/swagger.json"));
|
||||
var paths = document.RootElement.GetProperty("paths");
|
||||
|
||||
var publicOperation = paths
|
||||
.GetProperty("/api/v1/auth/login/dev")
|
||||
.GetProperty("post");
|
||||
var protectedOperation = paths
|
||||
.GetProperty("/api/v1/users")
|
||||
.GetProperty("get");
|
||||
|
||||
Assert.False(publicOperation.TryGetProperty("security", out _));
|
||||
Assert.True(protectedOperation.TryGetProperty("security", out var security));
|
||||
Assert.Equal("Bearer", security[0].EnumerateObject().Single().Name);
|
||||
Assert.Contains("Required roles:", protectedOperation.GetProperty("description").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Sync;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Sync;
|
||||
|
||||
public class ScheduleSyncServiceTests
|
||||
{
|
||||
private const string EventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a";
|
||||
private const string CourseId = "73aa6226-adbb-4e15-b264-e16fee19fd73";
|
||||
private const string PersonId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440";
|
||||
private const string FullName = "Иванов Иван Иванович";
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var modeus = Substitute.For<IModeusApiClient>();
|
||||
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
|
||||
.Returns(new ModeusEventsResponse
|
||||
{
|
||||
Embedded = new ModeusEventsEmbedded
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new ModeusEvent
|
||||
{
|
||||
Id = "event-1",
|
||||
Name = "Open lecture",
|
||||
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
|
||||
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc)
|
||||
}
|
||||
],
|
||||
EventRooms =
|
||||
[
|
||||
new ModeusEventRoom
|
||||
{
|
||||
Links = new ModeusEventRoomLinks
|
||||
{
|
||||
Event = new ModeusHrefLink("/events/event-1"),
|
||||
Room = new ModeusHrefLink("/rooms/room-1")
|
||||
}
|
||||
}
|
||||
],
|
||||
Rooms =
|
||||
[
|
||||
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 42)
|
||||
],
|
||||
EventTeams =
|
||||
[
|
||||
new ModeusEventTeam("event-1", 15)
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
var lecture = await db.Lectures.SingleAsync();
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(1, result.Created);
|
||||
Assert.Equal(42, lecture.MaxEnrollments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_LoadsRoomCapacityWhenEventRoomHasNoCapacity()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var modeus = Substitute.For<IModeusApiClient>();
|
||||
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
|
||||
.Returns(new ModeusEventsResponse
|
||||
{
|
||||
Embedded = new ModeusEventsEmbedded
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new ModeusEvent
|
||||
{
|
||||
Id = "event-1",
|
||||
Name = "Open lecture",
|
||||
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
|
||||
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc)
|
||||
}
|
||||
],
|
||||
EventRooms =
|
||||
[
|
||||
new ModeusEventRoom
|
||||
{
|
||||
Links = new ModeusEventRoomLinks
|
||||
{
|
||||
Event = new ModeusHrefLink("/events/event-1"),
|
||||
Room = new ModeusHrefLink("/rooms/room-1")
|
||||
}
|
||||
}
|
||||
],
|
||||
Rooms =
|
||||
[
|
||||
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: null, WorkingCapacity: null)
|
||||
],
|
||||
EventTeams =
|
||||
[
|
||||
new ModeusEventTeam("event-1", 15)
|
||||
]
|
||||
}
|
||||
});
|
||||
modeus.SearchRoomsAsync()
|
||||
.Returns(new ModeusRoomsResponse
|
||||
{
|
||||
Rooms =
|
||||
[
|
||||
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 48)
|
||||
]
|
||||
});
|
||||
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
var lecture = await db.Lectures.SingleAsync();
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(1, result.Created);
|
||||
Assert.Equal(48, lecture.MaxEnrollments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var modeus = new FakeModeusApiClient(BuildEventsResponse());
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(1, result.Created);
|
||||
|
||||
var lecture = await db.Lectures.Include(item => item.Teacher).SingleAsync();
|
||||
Assert.Equal("Иванов Иван Иванович", lecture.Teacher?.DisplayName);
|
||||
Assert.Equal("modeus-b5a5cad8-60c2-4d94-9972-8a0c2e981440@modeus.local", lecture.Teacher?.Email);
|
||||
|
||||
var teacherProfile = await db.TeacherProfiles.Include(item => item.User).SingleAsync();
|
||||
Assert.Equal("b5a5cad8-60c2-4d94-9972-8a0c2e981440", teacherProfile.ModeusId);
|
||||
Assert.Equal(teacherProfile.UserId, lecture.TeacherId);
|
||||
|
||||
var teacherRole = await db.UserRoles.SingleAsync();
|
||||
Assert.Equal(lecture.TeacherId, teacherRole.UserId);
|
||||
Assert.Equal(UserRole.Teacher, teacherRole.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_SavesResolvedTeacherSubId()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
|
||||
Assert.Equal("sso-sub-1", teacher.MicrosoftId);
|
||||
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
|
||||
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_UsesPlaceholderWhenSubLookupFails()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var modeus = new FakeModeusApiClient(BuildEventsResponse(), throwOnSubLookup: true);
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
|
||||
Assert.Null(teacher.MicrosoftId);
|
||||
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
|
||||
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_AttachesTeacherProfileToExistingSsoUser()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new UniVerse.Domain.Entities.User
|
||||
{
|
||||
Id = 77,
|
||||
Email = "teacher@sfedu.ru",
|
||||
DisplayName = "Old Name",
|
||||
MicrosoftId = "sso-sub-1",
|
||||
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 77, Role = UserRole.Student }]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Single(await db.Users.ToListAsync());
|
||||
var teacher = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
|
||||
Assert.Equal(77, teacher.Id);
|
||||
Assert.Equal("teacher@sfedu.ru", teacher.Email);
|
||||
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Student);
|
||||
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Teacher);
|
||||
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
|
||||
Assert.True(await db.Lectures.AnyAsync(lecture => lecture.TeacherId == 77));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_MergesPlaceholderIntoExistingSsoUserOnRetry()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var placeholder = new UniVerse.Domain.Entities.User
|
||||
{
|
||||
Id = 10,
|
||||
Email = $"modeus-{PersonId}@modeus.local",
|
||||
DisplayName = FullName,
|
||||
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
|
||||
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
|
||||
};
|
||||
db.Users.Add(placeholder);
|
||||
db.Users.Add(new UniVerse.Domain.Entities.User
|
||||
{
|
||||
Id = 20,
|
||||
Email = "teacher@sfedu.ru",
|
||||
DisplayName = FullName,
|
||||
MicrosoftId = "sso-sub-1",
|
||||
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 20, Role = UserRole.Student }]
|
||||
});
|
||||
db.Courses.Add(new UniVerse.Domain.Entities.Course { Id = 1, Name = "Course", ExternalId = CourseId, IsSynced = true });
|
||||
db.Lectures.Add(new UniVerse.Domain.Entities.Lecture
|
||||
{
|
||||
Id = 1,
|
||||
CourseId = 1,
|
||||
TeacherId = 10,
|
||||
ExternalId = EventId,
|
||||
Title = "Old",
|
||||
StartsAt = DateTime.UtcNow,
|
||||
EndsAt = DateTime.UtcNow.AddHours(1)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Single(await db.Users.ToListAsync());
|
||||
var realUser = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
|
||||
Assert.Equal(20, realUser.Id);
|
||||
Assert.Equal(PersonId, realUser.TeacherProfile?.ModeusId);
|
||||
Assert.Contains(realUser.Roles, role => role.Role == UserRole.Teacher);
|
||||
Assert.True(await db.Lectures.AllAsync(lecture => lecture.TeacherId == 20));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncScheduleAsync_DoesNotLookupSubWhenTeacherAlreadyHasMicrosoftId()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new UniVerse.Domain.Entities.User
|
||||
{
|
||||
Id = 10,
|
||||
Email = "teacher@sfedu.ru",
|
||||
DisplayName = FullName,
|
||||
MicrosoftId = "sso-sub-1",
|
||||
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
|
||||
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var modeus = Substitute.For<IModeusApiClient>();
|
||||
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>()).Returns(BuildEventsResponse());
|
||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
||||
|
||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private static ModeusEventsResponse BuildEventsResponse()
|
||||
{
|
||||
const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7";
|
||||
|
||||
return new ModeusEventsResponse
|
||||
{
|
||||
Embedded = new ModeusEventsEmbedded
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new ModeusEvent
|
||||
{
|
||||
Id = EventId,
|
||||
Name = "Тема 20. Управление ресурсами проекта. Часть 2.",
|
||||
TypeId = "LAB",
|
||||
StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc),
|
||||
EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc),
|
||||
Links = new ModeusEventLinks
|
||||
{
|
||||
CourseUnitRealization = new ModeusHrefLink($"/{CourseId}")
|
||||
}
|
||||
}
|
||||
],
|
||||
CourseUnitRealizations =
|
||||
[
|
||||
new ModeusCourseUnitRealization(
|
||||
CourseId,
|
||||
"Управление проектами разработки программного обеспечения",
|
||||
"УПРПО")
|
||||
],
|
||||
EventTeams = [new ModeusEventTeam(EventId, 25)],
|
||||
EventAttendees =
|
||||
[
|
||||
new ModeusEventAttendee
|
||||
{
|
||||
Id = attendeeId,
|
||||
RoleId = "TEACH",
|
||||
RoleName = "Преподаватель",
|
||||
Links = new ModeusEventAttendeeLinks
|
||||
{
|
||||
Event = new ModeusHrefLink($"/{EventId}"),
|
||||
Person = new ModeusHrefLink($"/{PersonId}")
|
||||
}
|
||||
}
|
||||
],
|
||||
Persons =
|
||||
[
|
||||
new ModeusPerson(
|
||||
PersonId,
|
||||
"Иванов",
|
||||
"Иван",
|
||||
"Иванович",
|
||||
FullName)
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeModeusApiClient(
|
||||
ModeusEventsResponse events,
|
||||
string? subId = null,
|
||||
bool throwOnSubLookup = false) : IModeusApiClient
|
||||
{
|
||||
public Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
|
||||
|
||||
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
|
||||
|
||||
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
|
||||
|
||||
public Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (throwOnSubLookup)
|
||||
throw new HttpRequestException("lookup failed");
|
||||
|
||||
return Task.FromResult(subId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,118 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Users;
|
||||
|
||||
public class UserServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_ReturnsLevelProgressThresholds()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
SeedLevelThresholds(db);
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 120 });
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
var stats = await service.GetStatsAsync(1);
|
||||
|
||||
Assert.Equal(2, stats.Level);
|
||||
Assert.Equal(100, stats.CurrentLevelXp);
|
||||
Assert.Equal(300, stats.NextLevelXp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_ReturnsNullNextLevelAtMaxConfiguredLevel()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
SeedLevelThresholds(db);
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
var stats = await service.GetStatsAsync(1);
|
||||
|
||||
Assert.Equal(3, stats.Level);
|
||||
Assert.Equal(300, stats.CurrentLevelXp);
|
||||
Assert.Null(stats.NextLevelXp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_ReturnsEnrollmentSlotStateAndRules()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
SeedLevelThresholds(db);
|
||||
var now = DateTime.UtcNow;
|
||||
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
|
||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
||||
db.Lectures.AddRange(
|
||||
Lecture(1, now.AddDays(1)),
|
||||
Lecture(2, now.AddDays(2)),
|
||||
Lecture(3, now.AddDays(-1)));
|
||||
db.LectureEnrollments.AddRange(
|
||||
new LectureEnrollment { LectureId = 1, UserId = 1 },
|
||||
new LectureEnrollment { LectureId = 2, UserId = 1 },
|
||||
new LectureEnrollment { LectureId = 3, UserId = 1 });
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
var stats = await service.GetStatsAsync(1);
|
||||
|
||||
Assert.Equal(3, stats.ActiveEnrollments);
|
||||
Assert.Equal(5, stats.EnrollmentSlotLimit);
|
||||
Assert.Equal(new[] { 1, 3, 4 }, stats.EnrollmentSlotRules.Select(rule => rule.Level));
|
||||
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots));
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"UserServiceTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private static UserService CreateService(AppDbContext db)
|
||||
{
|
||||
var notifications = Substitute.For<INotificationService>();
|
||||
notifications.CreateUserNotificationAsync(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
|
||||
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
|
||||
return new UserService(db, gamification);
|
||||
}
|
||||
|
||||
private static void SeedLevelThresholds(AppDbContext db)
|
||||
{
|
||||
db.LevelThresholds.AddRange(
|
||||
new LevelThreshold { Level = 1, RequiredXp = 0 },
|
||||
new LevelThreshold { Level = 2, RequiredXp = 100 },
|
||||
new LevelThreshold { Level = 3, RequiredXp = 300 });
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static Lecture Lecture(int id, DateTime startsAt) => new()
|
||||
{
|
||||
Id = id,
|
||||
CourseId = 1,
|
||||
Title = $"Lecture {id}",
|
||||
StartsAt = startsAt,
|
||||
EndsAt = startsAt.AddHours(2),
|
||||
IsOpen = true,
|
||||
MaxEnrollments = 30
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using UniVerse.Infrastructure.Data;
|
||||
|
||||
namespace UniVerse.Api.BackgroundServices;
|
||||
|
||||
public class AchievementCatalogHostedService : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public AchievementCatalogHostedService(IServiceProvider services) => _services = services;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await AchievementCatalogSeeder.SeedAsync(_services, cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.BackgroundServices;
|
||||
|
||||
public class LlmProcessingBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<LlmProcessingBackgroundService> _logger;
|
||||
|
||||
public LlmProcessingBackgroundService(IServiceProvider services, ILogger<LlmProcessingBackgroundService> logger)
|
||||
{
|
||||
_services = services; _logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("LLM Processing Background Service started");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
|
||||
await llmService.ProcessPendingReviewsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in LLM processing background service");
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading.Channels;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.BackgroundServices;
|
||||
|
||||
public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue
|
||||
{
|
||||
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(reviewId, cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<int> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using UniVerse.Api.Options;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
|
||||
namespace UniVerse.Api.BackgroundServices;
|
||||
|
||||
public sealed class ReviewAnalysisWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ReviewAnalysisQueue _queue;
|
||||
private readonly ReviewAnalysisOptions _options;
|
||||
private readonly ILogger<ReviewAnalysisWorker> _logger;
|
||||
|
||||
public ReviewAnalysisWorker(
|
||||
IServiceProvider services,
|
||||
ReviewAnalysisQueue queue,
|
||||
IOptions<ReviewAnalysisOptions> options,
|
||||
ILogger<ReviewAnalysisWorker> logger)
|
||||
{
|
||||
_services = services;
|
||||
_queue = queue;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var maxConcurrency = Math.Max(1, _options.MaxConcurrentProcessing);
|
||||
_logger.LogInformation(
|
||||
"Review analysis worker started with max concurrency {MaxConcurrency}",
|
||||
maxConcurrency);
|
||||
|
||||
await EnqueueExistingPendingReviewsAsync(stoppingToken);
|
||||
|
||||
var workers = Enumerable.Range(1, maxConcurrency)
|
||||
.Select(workerNumber => ProcessQueueAsync(workerNumber, stoppingToken))
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(workers);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Review analysis worker stopped");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnqueueExistingPendingReviewsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var pendingReviewIds = await db.Reviews
|
||||
.Where(r => r.LlmStatus == ReviewLlmStatus.Pending)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var reviewId in pendingReviewIds)
|
||||
await _queue.EnqueueAsync(reviewId, cancellationToken);
|
||||
|
||||
if (pendingReviewIds.Count > 0)
|
||||
_logger.LogInformation(
|
||||
"Queued {ReviewCount} pending reviews for immediate analysis",
|
||||
pendingReviewIds.Count);
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync(int workerNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var reviewId in _queue.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
|
||||
await llmService.AnalyzeReviewAsync(reviewId);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Review analysis worker {WorkerNumber} failed to process review {ReviewId}",
|
||||
workerNumber,
|
||||
reviewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,87 @@ using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление определениями достижений системы геймификации.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/achievements")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class AchievementsController : ControllerBase
|
||||
{
|
||||
private readonly IAchievementService _achievements;
|
||||
|
||||
public AchievementsController(IAchievementService achievements) => _achievements = achievements;
|
||||
|
||||
/// <summary>Получить список всех достижений.</summary>
|
||||
/// <remarks>Возвращает определения достижений (без информации о получении конкретным пользователем).
|
||||
/// Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements.</remarks>
|
||||
/// <response code="200">Список достижений.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<AchievementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll() => Ok(await _achievements.GetAllAsync());
|
||||
|
||||
/// <summary>Получить достижение по ID.</summary>
|
||||
/// <param name="id">ID достижения.</param>
|
||||
/// <response code="200">Данные достижения.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Достижение не найдено.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AchievementDto>> Get(int id) => Ok(await _achievements.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Создать новое достижение.</summary>
|
||||
/// <remarks>Только Admin. Достижения автоматически присваиваются студентам при выполнении условий.</remarks>
|
||||
/// <param name="req">Название, описание, иконка, награда в XP/монетах и условие получения.</param>
|
||||
/// <response code="201">Достижение создано.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить достижение по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID достижения.</param>
|
||||
/// <param name="req">Обновляемые поля достижения.</param>
|
||||
/// <response code="200">Обновлённые данные достижения.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Достижение не найдено.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AchievementDto>> Update(int id, [FromBody] UpdateAchievementRequest req) =>
|
||||
Ok(await _achievements.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить достижение по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Удаление не отзывает достижение у уже получивших его пользователей.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID достижения.</param>
|
||||
/// <response code="204">Достижение удалено.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Достижение не найдено.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _achievements.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,208 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using UniVerse.Application.DTOs.Auth;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Enums;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Аутентификация и управление сессией пользователя.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
[Produces("application/json")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _auth;
|
||||
public AuthController(IAuthService auth) => _auth = auth;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
private const string MicrosoftStateCookieName = "msAuthState";
|
||||
private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl";
|
||||
|
||||
public AuthController(IAuthService auth, IConfiguration config)
|
||||
{
|
||||
_auth = auth;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>Вход через Microsoft Entra ID (SPA/PKCE flow).</summary>
|
||||
/// <remarks>
|
||||
/// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
|
||||
/// полученный authorization code. В ответ возвращается пара JWT-токенов;
|
||||
/// refresh token устанавливается в HttpOnly cookie.
|
||||
/// </remarks>
|
||||
/// <param name="request">Authorization code и redirect URI из Microsoft OAuth2.</param>
|
||||
/// <response code="200">Успешный вход — возвращает access token и данные пользователя.</response>
|
||||
/// <response code="400">Неверный или просроченный authorization code.</response>
|
||||
[HttpPost("login/microsoft")]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
||||
{
|
||||
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode);
|
||||
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri, GetClientIpAddress());
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
|
||||
/// <remarks>
|
||||
/// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
|
||||
/// и редиректит пользователя на `login.microsoftonline.com`.
|
||||
/// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
|
||||
/// </remarks>
|
||||
/// <param name="returnUrl">URL для редиректа после успешного входа (опционально).</param>
|
||||
/// <response code="302">Редирект на Microsoft authorize endpoint.</response>
|
||||
/// <response code="500">Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).</response>
|
||||
[HttpGet("login/microsoft")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
|
||||
{
|
||||
var tenantId = _config["AzureAd:TenantId"];
|
||||
var clientId = _config["AzureAd:ClientId"];
|
||||
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
|
||||
return Problem("Microsoft authentication is not configured (AzureAd:TenantId/ClientId).", statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
|
||||
|
||||
var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
|
||||
{
|
||||
Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
|
||||
});
|
||||
}
|
||||
|
||||
var authorizeEndpoint = $"{instance.TrimEnd('/')}/{tenantId}/oauth2/v2.0/authorize";
|
||||
var scope = _config["AzureAd:Scopes"] ?? "openid profile email offline_access User.Read";
|
||||
|
||||
var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary<string, string?>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["response_type"] = "code",
|
||||
["redirect_uri"] = redirectUri,
|
||||
["response_mode"] = "query",
|
||||
["scope"] = scope,
|
||||
["state"] = state
|
||||
});
|
||||
|
||||
return Redirect(authorizeUrl);
|
||||
}
|
||||
|
||||
/// <summary>OAuth2 callback — обмен code на токены (server-driven flow).</summary>
|
||||
/// <remarks>
|
||||
/// Microsoft редиректит браузер сюда после успешного входа.
|
||||
/// Backend валидирует CSRF state, обменивает code на токены,
|
||||
/// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
|
||||
/// </remarks>
|
||||
/// <param name="code">Authorization code от Microsoft.</param>
|
||||
/// <param name="state">CSRF state для верификации.</param>
|
||||
/// <param name="error">Код ошибки от Microsoft (если вход не удался).</param>
|
||||
/// <param name="errorDescription">Описание ошибки от Microsoft.</param>
|
||||
/// <response code="302">Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.</response>
|
||||
/// <response code="200">Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).</response>
|
||||
/// <response code="400">Отсутствует authorization code.</response>
|
||||
/// <response code="401">Ошибка от Microsoft или невалидный CSRF state.</response>
|
||||
[HttpGet("callback/microsoft")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> CallbackMicrosoft(
|
||||
[FromQuery] string? code = null,
|
||||
[FromQuery] string? state = null,
|
||||
[FromQuery] string? error = null,
|
||||
[FromQuery(Name = "error_description")] string? errorDescription = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
return Unauthorized(new
|
||||
{
|
||||
error,
|
||||
errorDescription
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return BadRequest(new { error = "missing_code" });
|
||||
|
||||
var expectedState = Request.Cookies[MicrosoftStateCookieName];
|
||||
if (string.IsNullOrWhiteSpace(expectedState) || string.IsNullOrWhiteSpace(state) || !string.Equals(expectedState, state, StringComparison.Ordinal))
|
||||
return Unauthorized(new { error = "invalid_state" });
|
||||
|
||||
Response.Cookies.Delete(MicrosoftStateCookieName);
|
||||
|
||||
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
|
||||
|
||||
var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri, GetClientIpAddress());
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
|
||||
var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"];
|
||||
Response.Cookies.Delete(MicrosoftReturnUrlCookieName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
|
||||
{
|
||||
// Put access token in URL fragment so it is not sent as Referer to the backend.
|
||||
// Frontend can read it from location.hash on the landing page.
|
||||
var fragment = $"access_token={Uri.EscapeDataString(result.Response.AccessToken)}&expires_at={Uri.EscapeDataString(result.Response.ExpiresAt.ToString("O"))}";
|
||||
return Redirect($"{returnUrl}#{fragment}");
|
||||
}
|
||||
|
||||
// Useful for manual testing without frontend: you'll see JSON in the browser.
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
/// <summary>Dev-only вход без OAuth (только в Development-окружении).</summary>
|
||||
/// <remarks>
|
||||
/// Создаёт или находит пользователя по email без реального OAuth flow.
|
||||
/// Возвращает 404 в Production и Staging.
|
||||
/// </remarks>
|
||||
/// <param name="request">Email, отображаемое имя и роль тестового пользователя.</param>
|
||||
/// <response code="200">Успешный вход.</response>
|
||||
/// <response code="404">Endpoint недоступен вне Development.</response>
|
||||
[HttpPost("login/dev")]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
||||
{
|
||||
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
|
||||
return NotFound();
|
||||
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role);
|
||||
var roles = request.Roles?.Count > 0 ? request.Roles : [UserRole.Student];
|
||||
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, roles, GetClientIpAddress());
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
/// <summary>Обновление access token по refresh token из HttpOnly cookie.</summary>
|
||||
/// <remarks>
|
||||
/// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
|
||||
/// Возвращает новую пару токенов и обновляет cookie.
|
||||
/// </remarks>
|
||||
/// <response code="200">Новая пара токенов.</response>
|
||||
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
|
||||
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
|
||||
[HttpPost("refresh")]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<AuthResponse>> Refresh()
|
||||
{
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
@@ -41,8 +212,17 @@ public class AuthController : ControllerBase
|
||||
return Ok(result.Response);
|
||||
}
|
||||
|
||||
/// <summary>Выход из системы — отзыв refresh token.</summary>
|
||||
/// <remarks>
|
||||
/// Инвалидирует текущий refresh token в БД и удаляет cookie.
|
||||
/// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
|
||||
/// </remarks>
|
||||
/// <response code="204">Выход выполнен успешно.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[Authorize]
|
||||
[HttpPost("logout")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
@@ -52,8 +232,16 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
|
||||
/// <response code="200">Данные текущего пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
|
||||
[Authorize]
|
||||
[HttpGet("me")]
|
||||
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.CurrentUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Me()
|
||||
{
|
||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
@@ -62,6 +250,21 @@ public class AuthController : ControllerBase
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
private string? GetClientIpAddress()
|
||||
{
|
||||
if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
|
||||
{
|
||||
var firstForwardedAddress = forwardedFor.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(firstForwardedAddress))
|
||||
return firstForwardedAddress;
|
||||
}
|
||||
|
||||
if (Request.Headers.TryGetValue("X-Real-IP", out var realIp) && !string.IsNullOrWhiteSpace(realIp))
|
||||
return realIp;
|
||||
|
||||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
private void SetRefreshTokenCookie(string token)
|
||||
{
|
||||
Response.Cookies.Append("refreshToken", token, new CookieOptions
|
||||
@@ -70,4 +273,32 @@ public class AuthController : ControllerBase
|
||||
Expires = DateTime.UtcNow.AddDays(30)
|
||||
});
|
||||
}
|
||||
|
||||
private string BuildAbsoluteUrl(string path)
|
||||
{
|
||||
if (!path.StartsWith('/')) path = "/" + path;
|
||||
return $"{Request.Scheme}://{Request.Host}{path}";
|
||||
}
|
||||
|
||||
private bool IsAllowedReturnUrl(string returnUrl)
|
||||
{
|
||||
if (Uri.TryCreate(returnUrl, UriKind.Relative, out _))
|
||||
return true;
|
||||
|
||||
if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute))
|
||||
return false;
|
||||
|
||||
var allowedOrigins = _config.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
||||
foreach (var origin in allowedOrigins)
|
||||
{
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var allowed))
|
||||
continue;
|
||||
if (string.Equals(allowed.Scheme, absolute.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(allowed.Host, absolute.Host, StringComparison.OrdinalIgnoreCase)
|
||||
&& allowed.Port == absolute.Port)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,132 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.DTOs.Courses;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление курсами (дисциплинами) и их тегами.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/courses")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class CoursesController : ControllerBase
|
||||
{
|
||||
private readonly ICourseService _courses;
|
||||
|
||||
public CoursesController(ICourseService courses) => _courses = courses;
|
||||
|
||||
/// <summary>Получить список курсов с фильтрацией и пагинацией.</summary>
|
||||
/// <param name="filter">Фильтры: tagId, search, isSynced; параметры пагинации.</param>
|
||||
/// <response code="200">Список курсов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<CourseDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
|
||||
Ok(await _courses.GetAllAsync(filter));
|
||||
|
||||
/// <summary>Получить курс по ID (включая теги).</summary>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <response code="200">Данные курса с тегами.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Курс не найден.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CourseDto>> Get(int id) => Ok(await _courses.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Создать новый курс.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="req">Название и описание курса.</param>
|
||||
/// <response code="201">Курс создан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить курс по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <param name="req">Новое название и/или описание.</param>
|
||||
/// <response code="200">Обновлённые данные курса.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CourseDto>> Update(int id, [FromBody] UpdateCourseRequest req) =>
|
||||
Ok(await _courses.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить курс по ID.</summary>
|
||||
/// <remarks>Только Admin. Удаление курса каскадно удаляет связанные лекции.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <response code="204">Курс удалён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _courses.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Привязать тег к курсу.</summary>
|
||||
/// <remarks>Только Admin. Тег должен существовать в системе.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <param name="tagId">ID тега.</param>
|
||||
/// <response code="204">Тег привязан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс или тег не найден.</response>
|
||||
/// <response code="409">Тег уже привязан к курсу.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("{id:int}/tags")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> AddTag(int id, [FromBody] int tagId)
|
||||
{ await _courses.AddTagAsync(id, tagId); return NoContent(); }
|
||||
{
|
||||
await _courses.AddTagAsync(id, tagId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отвязать тег от курса.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID курса.</param>
|
||||
/// <param name="tagId">ID тега.</param>
|
||||
/// <response code="204">Тег отвязан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Курс или тег не найден, либо связь не существует.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}/tags/{tagId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> RemoveTag(int id, int tagId)
|
||||
{ await _courses.RemoveTagAsync(id, tagId); return NoContent(); }
|
||||
{
|
||||
await _courses.RemoveTagAsync(id, tagId);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,59 +7,203 @@ using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Каталог лекций — просмотр, управление, запись и отзывы.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/lectures")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class LecturesController : ControllerBase
|
||||
{
|
||||
private readonly ILectureService _lectures;
|
||||
private readonly IReviewService _reviews;
|
||||
|
||||
public LecturesController(ILectureService lectures, IReviewService reviews)
|
||||
{ _lectures = lectures; _reviews = reviews; }
|
||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
{
|
||||
_lectures = lectures;
|
||||
_reviews = reviews;
|
||||
}
|
||||
|
||||
private int CurrentUserId => int.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
private bool CurrentUserIsAdmin => User.IsInRole("Admin");
|
||||
|
||||
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
|
||||
/// <param name="filter">
|
||||
/// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline),
|
||||
/// isOpen, tagId, search; параметры пагинации.
|
||||
/// </param>
|
||||
/// <remarks>Включает флаг `isEnrolled` — записан ли текущий пользователь на лекцию.</remarks>
|
||||
/// <response code="200">Список лекций (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<LectureDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] LectureFilterRequest filter) =>
|
||||
Ok(await _lectures.GetAllAsync(filter));
|
||||
Ok(await _lectures.GetAllAsync(filter, CurrentUserId));
|
||||
|
||||
/// <summary>Получить детальную карточку лекции по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="200">Детальные данные лекции.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Get(int id) =>
|
||||
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
|
||||
|
||||
/// <summary>Создать новую лекцию.</summary>
|
||||
/// <remarks>Только Admin. Курс задаётся при создании и не может быть изменён.</remarks>
|
||||
/// <param name="req">Данные лекции: курс, преподаватель, локация, время, формат, вместимость.</param>
|
||||
/// <response code="201">Лекция создана.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить лекцию по ID.</summary>
|
||||
/// <remarks>Admin или Teacher. CourseId изменить нельзя.</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="req">Обновляемые поля: преподаватель, локация, время, формат, описание.</param>
|
||||
/// <response code="200">Обновлённые данные лекции.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
|
||||
Ok(await _lectures.UpdateAsync(id, req));
|
||||
Ok(await _lectures.UpdateAsync(id, req, CurrentUserId, CurrentUserIsAdmin));
|
||||
|
||||
/// <summary>Удалить лекцию по ID.</summary>
|
||||
/// <remarks>Только Admin. Каскадно удаляет записи и отзывы.</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="204">Лекция удалена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _lectures.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Записаться на лекцию.</summary>
|
||||
/// <remarks>
|
||||
/// Только Student. Проверяет наличие свободных мест и отсутствие повторной записи.
|
||||
/// После посещения начисляются монеты через gamification.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="204">Запись выполнена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Student.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
/// <response code="409">Студент уже записан или мест нет.</response>
|
||||
[Authorize(Roles = "Student")]
|
||||
[HttpPost("{id:int}/enroll")]
|
||||
public async Task<IActionResult> Enroll(int id) { await _lectures.EnrollAsync(id, CurrentUserId); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Enroll(int id)
|
||||
{
|
||||
await _lectures.EnrollAsync(id, CurrentUserId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отменить запись на лекцию.</summary>
|
||||
/// <remarks>Только Student. Отменить можно только свою запись.</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <response code="204">Запись отменена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Student.</response>
|
||||
/// <response code="404">Лекция или запись не найдена.</response>
|
||||
[Authorize(Roles = "Student")]
|
||||
[HttpDelete("{id:int}/enroll")]
|
||||
public async Task<IActionResult> Unenroll(int id) { await _lectures.UnenrollAsync(id, CurrentUserId); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Unenroll(int id)
|
||||
{
|
||||
await _lectures.UnenrollAsync(id, CurrentUserId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отметить посещение студента на лекции.</summary>
|
||||
/// <remarks>
|
||||
/// Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение
|
||||
/// через gamification service.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="userId">ID студента.</param>
|
||||
/// <param name="attended">true — посетил, false — не посетил.</param>
|
||||
/// <response code="204">Посещение отмечено.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Лекция или запись студента не найдена.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpPatch("{id:int}/attendance/{userId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
|
||||
{ await _lectures.MarkAttendanceAsync(id, userId, attended); return NoContent(); }
|
||||
{
|
||||
await _lectures.MarkAttendanceAsync(id, userId, attended, CurrentUserId, CurrentUserIsAdmin);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Получить список записавшихся студентов на лекцию.</summary>
|
||||
/// <remarks>Только Admin или Teacher. Включает флаг посещения (`attended`).</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список записей (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpGet("{id:int}/enrollments")]
|
||||
[ProducesResponseType(typeof(PagedResult<EnrollmentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
|
||||
Ok(await _lectures.GetEnrollmentsAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
|
||||
|
||||
/// <summary>Получить отзывы к лекции.</summary>
|
||||
/// <remarks>Только Admin или Teacher.</remarks>
|
||||
/// <param name="id">ID лекции.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpGet("{id:int}/reviews")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetByLectureAsync(id, pagination));
|
||||
|
||||
Ok(await _reviews.GetByLectureAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
|
||||
}
|
||||
|
||||
@@ -5,31 +5,85 @@ using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление локациями проведения лекций (аудитории, онлайн-площадки).</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/locations")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class LocationsController : ControllerBase
|
||||
{
|
||||
private readonly ILocationService _locations;
|
||||
|
||||
public LocationsController(ILocationService locations) => _locations = locations;
|
||||
|
||||
/// <summary>Получить список всех локаций.</summary>
|
||||
/// <response code="200">Список локаций.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<LocationDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll() => Ok(await _locations.GetAllAsync());
|
||||
|
||||
/// <summary>Получить локацию по ID.</summary>
|
||||
/// <param name="id">ID локации.</param>
|
||||
/// <response code="200">Данные локации.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Локация не найдена.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LocationDto>> Get(int id) => Ok(await _locations.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Создать новую локацию.</summary>
|
||||
/// <remarks>Только Admin. Локации также создаются автоматически при синхронизации с Modeus.</remarks>
|
||||
/// <param name="req">Название, корпус, аудитория и/или адрес.</param>
|
||||
/// <response code="201">Локация создана.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить локацию по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID локации.</param>
|
||||
/// <param name="req">Обновляемые поля: название, корпус, аудитория, адрес.</param>
|
||||
/// <response code="200">Обновлённые данные локации.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Локация не найдена.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LocationDto>> Update(int id, [FromBody] UpdateLocationRequest req) =>
|
||||
Ok(await _locations.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить локацию по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. При удалении локации у связанных лекций поле `locationId` становится null.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID локации.</param>
|
||||
/// <response code="204">Локация удалена.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Локация не найдена.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _locations.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Отправка и планирование уведомлений через доступные каналы.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/notifications")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class NotificationsController : ControllerBase
|
||||
{
|
||||
private readonly INotificationService _notifications;
|
||||
|
||||
public NotificationsController(INotificationService notifications)
|
||||
{
|
||||
_notifications = notifications;
|
||||
}
|
||||
|
||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
|
||||
/// <summary>Получить уведомления текущего пользователя.</summary>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <param name="cancellationToken">Токен отмены запроса.</param>
|
||||
/// <response code="200">Список уведомлений.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<UserNotificationDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<PagedResult<UserNotificationDto>>> GetMine(
|
||||
[FromQuery] PaginationRequest pagination,
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await _notifications.GetUserNotificationsAsync(CurrentUserId, pagination, cancellationToken));
|
||||
|
||||
/// <summary>Отметить все уведомления текущего пользователя как прочитанные.</summary>
|
||||
/// <param name="cancellationToken">Токен отмены запроса.</param>
|
||||
/// <response code="204">Уведомления отмечены прочитанными.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpPatch("read-all")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> MarkAllRead(CancellationToken cancellationToken)
|
||||
{
|
||||
await _notifications.MarkAllReadAsync(CurrentUserId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Отправить уведомление немедленно.</summary>
|
||||
/// <remarks>
|
||||
/// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`.
|
||||
/// </remarks>
|
||||
/// <param name="request">Канал, получатель, тема и текст уведомления.</param>
|
||||
/// <response code="202">Уведомление принято к отправке.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("send")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> Send([FromBody] SendNotificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = new NotificationMessage(
|
||||
request.Channel,
|
||||
request.Recipient,
|
||||
request.Subject,
|
||||
request.Body,
|
||||
request.RecipientName,
|
||||
request.Metadata);
|
||||
|
||||
await _notifications.SendAsync(message, cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
/// <summary>Запланировать отложенную отправку уведомления через Quartz.NET.</summary>
|
||||
/// <param name="request">Уведомление и момент отправки.</param>
|
||||
/// <response code="202">Уведомление поставлено в очередь Quartz.NET.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("schedule")]
|
||||
[ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<ScheduledNotificationResponse>> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _notifications.ScheduleAsync(request, cancellationToken);
|
||||
return Accepted(response);
|
||||
}
|
||||
}
|
||||
@@ -7,40 +7,161 @@ using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Отзывы студентов на лекции с LLM-анализом и модерацией.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/reviews")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class ReviewsController : ControllerBase
|
||||
{
|
||||
private readonly IReviewService _reviews;
|
||||
public ReviewsController(IReviewService reviews) => _reviews = reviews;
|
||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
private readonly IReviewPromptService _reviewPrompts;
|
||||
|
||||
public ReviewsController(IReviewService reviews, IReviewPromptService reviewPrompts)
|
||||
{
|
||||
_reviews = reviews;
|
||||
_reviewPrompts = reviewPrompts;
|
||||
}
|
||||
|
||||
private int CurrentUserId => int.Parse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
|
||||
/// <summary>Создать отзыв к лекции.</summary>
|
||||
/// <remarks>
|
||||
/// Только Student. После создания отзыв отправляется на LLM-анализ
|
||||
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
|
||||
/// скрытно от пользователя.
|
||||
/// </remarks>
|
||||
/// <param name="req">ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.</param>
|
||||
/// <response code="201">Отзыв создан и поставлен в очередь на LLM-анализ.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Student.</response>
|
||||
/// <response code="404">Лекция не найдена.</response>
|
||||
/// <response code="409">Студент уже оставил отзыв к этой лекции.</response>
|
||||
[Authorize(Roles = "Student")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
|
||||
|
||||
/// <summary>Получить список всех отзывов.</summary>
|
||||
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
|
||||
/// <param name="filter">Параметры фильтрации и пагинации.</param>
|
||||
/// <response code="200">Список всех отзывов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> List([FromQuery] ReviewFilterRequest filter) =>
|
||||
Ok(await _reviews.GetAllAsync(filter));
|
||||
|
||||
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
|
||||
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
|
||||
/// <response code="200">Текущий шаблон промпта.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("llm-prompt")]
|
||||
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<ReviewPromptDto>> GetLlmPrompt() =>
|
||||
Ok(await _reviewPrompts.GetAsync());
|
||||
|
||||
/// <summary>Обновить промпт LLM-анализа отзывов.</summary>
|
||||
/// <remarks>Только Admin. Промпт применяется к следующим анализам и ручным повторам.</remarks>
|
||||
/// <param name="request">Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}.</param>
|
||||
/// <response code="200">Сохранённый шаблон промпта.</response>
|
||||
/// <response code="400">Промпт пустой или не содержит обязательные плейсхолдеры.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("llm-prompt")]
|
||||
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<ReviewPromptDto>> UpdateLlmPrompt([FromBody] UpdateReviewPromptRequest request) =>
|
||||
Ok(await _reviewPrompts.UpdateAsync(request));
|
||||
|
||||
/// <summary>Получить отзыв по ID.</summary>
|
||||
/// <remarks>Только Admin или Teacher.</remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <response code="200">Данные отзыва (включая LLM-статус и сентимент).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin или Teacher.</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[Authorize(Roles = "Admin,Teacher")]
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ReviewDto>> Get(int id) => Ok(await _reviews.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Обновить отзыв.</summary>
|
||||
/// <remarks>
|
||||
/// Разрешено любому авторизованному пользователю, но сервис проверяет владельца.
|
||||
/// Изменение текста сбрасывает LLM-статус в `Pending` (повторный анализ).
|
||||
/// </remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <param name="req">Новая оценка и/или текст.</param>
|
||||
/// <response code="200">Обновлённые данные отзыва.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Отзыв принадлежит другому пользователю.</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ReviewDto>> Update(int id, [FromBody] UpdateReviewRequest req) =>
|
||||
Ok(await _reviews.UpdateAsync(id, CurrentUserId, req));
|
||||
|
||||
/// <summary>Удалить отзыв.</summary>
|
||||
/// <remarks>Владелец может удалить свой отзыв. Admin может удалить любой.</remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <response code="204">Отзыв удалён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Нет прав на удаление (не владелец и не Admin).</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("pending")]
|
||||
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetPendingAsync(pagination));
|
||||
|
||||
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его
|
||||
/// на повторную обработку.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <response code="204">Повторный анализ запланирован.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Отзыв не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("{id:int}/reanalyze")]
|
||||
public async Task<IActionResult> Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Reanalyze(int id)
|
||||
{
|
||||
await _reviews.ReanalyzeAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,28 +5,75 @@ using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Синхронизация данных из внешней системы расписания Modeus (только Admin).</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/sync")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Produces("application/json")]
|
||||
public class SyncController : ControllerBase
|
||||
{
|
||||
private readonly IScheduleSyncService _sync;
|
||||
|
||||
public SyncController(IScheduleSyncService sync) => _sync = sync;
|
||||
|
||||
/// <summary>Запустить синхронизацию расписания лекций из Modeus.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных
|
||||
/// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду,
|
||||
/// размеру выборки, аудиториям, участникам, реализациям курсов/циклов,
|
||||
/// специальностям, годам набора, профилям, учебным планам и типам занятий.
|
||||
/// </remarks>
|
||||
/// <param name="req">Параметры поиска событий во внешнем сервисе расписания.</param>
|
||||
/// <response code="200">Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpPost("schedule")]
|
||||
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
|
||||
Ok(await _sync.SyncScheduleAsync(req));
|
||||
|
||||
/// <summary>Получить статус последней синхронизации.</summary>
|
||||
/// <remarks>Только Admin. Возвращает время и результат последней успешной синхронизации.</remarks>
|
||||
/// <response code="200">Статус синхронизации.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpGet("status")]
|
||||
[ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<SyncStatusDto>> Status() =>
|
||||
Ok(await _sync.GetLastSyncStatusAsync());
|
||||
|
||||
/// <summary>Синхронизировать аудитории (локации) из Modeus.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт
|
||||
/// соответствующие записи в таблице locations.
|
||||
/// </remarks>
|
||||
/// <response code="200">Результат синхронизации аудиторий.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpPost("rooms")]
|
||||
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
|
||||
Ok(await _sync.SyncRoomsAsync());
|
||||
|
||||
/// <summary>Поиск преподавателей в Modeus по ФИО.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Ищет преподавателей через внешнее API и возвращает список
|
||||
/// для ручного импорта. Найденные преподаватели не создаются автоматически.
|
||||
/// </remarks>
|
||||
/// <param name="fullname">Полное имя или часть имени преподавателя для поиска.</param>
|
||||
/// <response code="200">Список найденных преподавателей.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[HttpPost("employees")]
|
||||
[ProducesResponseType(typeof(List<EmployeeDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
|
||||
Ok(await _sync.SearchEmployeesAsync(fullname));
|
||||
|
||||
}
|
||||
|
||||
@@ -6,35 +6,101 @@ using UniVerse.Domain.Enums;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление тегами для категоризации курсов (институты, факультеты, темы и др.).</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/tags")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class TagsController : ControllerBase
|
||||
{
|
||||
private readonly ITagService _tags;
|
||||
|
||||
public TagsController(ITagService tags) => _tags = tags;
|
||||
|
||||
/// <summary>Получить список тегов с опциональной фильтрацией по типу и родителю.</summary>
|
||||
/// <param name="type">Тип тега: Institute, Faculty, Subject, Organization, Topic, Other.</param>
|
||||
/// <param name="parentId">ID родительского тега (фильтрация дочерних).</param>
|
||||
/// <response code="200">Список тегов.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<TagDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
|
||||
Ok(await _tags.GetAllAsync(type, parentId));
|
||||
|
||||
/// <summary>Получить тег по ID.</summary>
|
||||
/// <param name="id">ID тега.</param>
|
||||
/// <response code="200">Данные тега.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Тег не найден.</response>
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TagDto>> Get(int id) => Ok(await _tags.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Получить иерархическое дерево всех тегов.</summary>
|
||||
/// <remarks>
|
||||
/// Возвращает корневые теги с вложенными дочерними тегами.
|
||||
/// Полезно для построения фильтрующих UI-компонентов.
|
||||
/// </remarks>
|
||||
/// <response code="200">Иерархический список тегов.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet("tree")]
|
||||
[ProducesResponseType(typeof(List<TagTreeDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> GetTree() => Ok(await _tags.GetTreeAsync());
|
||||
|
||||
/// <summary>Создать новый тег.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="req">Название, тип и опциональный родительский тег.</param>
|
||||
/// <response code="201">Тег создан.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
|
||||
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req));
|
||||
|
||||
/// <summary>Обновить тег по ID.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="id">ID тега.</param>
|
||||
/// <param name="req">Новое название, тип и/или родительский тег.</param>
|
||||
/// <response code="200">Обновлённые данные тега.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Тег не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TagDto>> Update(int id, [FromBody] UpdateTagRequest req) =>
|
||||
Ok(await _tags.UpdateAsync(id, req));
|
||||
|
||||
/// <summary>Удалить тег по ID.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`).
|
||||
/// Дочерние теги остаются, но их `parentId` становится null.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID тега.</param>
|
||||
/// <response code="204">Тег удалён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Тег не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); }
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _tags.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,71 +8,269 @@ using System.Security.Claims;
|
||||
|
||||
namespace UniVerse.Api.Controllers;
|
||||
|
||||
/// <summary>Управление пользователями, профилями и геймификацией.</summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserService _users;
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IGamificationService _gamification;
|
||||
|
||||
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
|
||||
{
|
||||
_users = users; _reviews = reviews; _gamification = gamification;
|
||||
}
|
||||
|
||||
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
|
||||
|
||||
private static CurrentUserDto ToCurrentUserDto(UserDto user) => new(
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.DisplayName,
|
||||
user.AvatarUrl,
|
||||
user.Roles,
|
||||
user.Xp,
|
||||
user.Coins,
|
||||
user.Level,
|
||||
user.CreatedAt);
|
||||
|
||||
/// <summary>Получить профиль текущего пользователя.</summary>
|
||||
/// <response code="200">Данные текущего пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[HttpGet("me")]
|
||||
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CurrentUserDto>> GetMe() =>
|
||||
Ok(ToCurrentUserDto(await _users.GetByIdAsync(CurrentUserId)));
|
||||
|
||||
/// <summary>Обновить профиль текущего пользователя (displayName, avatarUrl).</summary>
|
||||
/// <param name="req">Обновляемые поля профиля.</param>
|
||||
/// <response code="200">Обновлённые данные текущего пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[HttpPut("me")]
|
||||
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CurrentUserDto>> UpdateMe([FromBody] UpdateUserRequest req) =>
|
||||
Ok(ToCurrentUserDto(await _users.UpdateProfileAsync(CurrentUserId, req)));
|
||||
|
||||
/// <summary>Получить статистику текущего пользователя.</summary>
|
||||
/// <response code="200">Статистика текущего пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[HttpGet("me/stats")]
|
||||
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserStatsDto>> MyStats() =>
|
||||
Ok(await _users.GetStatsAsync(CurrentUserId));
|
||||
|
||||
/// <summary>Получить список записей текущего пользователя на лекции.</summary>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список записей (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[HttpGet("me/enrollments")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
|
||||
|
||||
/// <summary>Получить отзывы текущего пользователя.</summary>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet("me/reviews")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> MyReviews([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetByUserAsync(CurrentUserId, pagination));
|
||||
|
||||
/// <summary>Получить достижения текущего пользователя.</summary>
|
||||
/// <response code="200">Список полученных достижений.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet("me/achievements")]
|
||||
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> MyAchievements() =>
|
||||
Ok(await _gamification.GetUserAchievementsAsync(CurrentUserId));
|
||||
|
||||
/// <summary>Получить историю транзакций монет текущего пользователя.</summary>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">История транзакций (пагинированная).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
[HttpGet("me/transactions")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult> MyTransactions([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _gamification.GetTransactionsAsync(CurrentUserId, pagination));
|
||||
|
||||
/// <summary>Получить профиль пользователя по ID.</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <response code="200">Данные пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserDto>> Get(int id) => Ok(await _users.GetByIdAsync(id));
|
||||
|
||||
/// <summary>Обновить профиль пользователя (displayName, avatarUrl).</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте PUT /api/v1/users/me.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="req">Обновляемые поля профиля.</param>
|
||||
/// <response code="200">Обновлённые данные пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
|
||||
{
|
||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||
return Ok(await _users.UpdateProfileAsync(id, req));
|
||||
}
|
||||
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req) =>
|
||||
Ok(await _users.UpdateProfileAsync(id, req));
|
||||
|
||||
/// <summary>Получить статистику пользователя (XP, монеты, уровень, посещения).</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <response code="200">Статистика пользователя.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("{id:int}/stats")]
|
||||
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
||||
|
||||
/// <summary>Получить список записей пользователя на лекции.</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список записей (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("{id:int}/enrollments")]
|
||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
|
||||
{
|
||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||
// Delegate to lecture service would be more proper, but returning reviews for now
|
||||
return Ok();
|
||||
}
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _users.GetEnrollmentsAsync(id, pagination));
|
||||
|
||||
/// <summary>Получить отзывы пользователя.</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("{id:int}/reviews")]
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetByUserAsync(id, pagination));
|
||||
|
||||
/// <summary>Получить достижения пользователя.</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <response code="200">Список полученных достижений.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("{id:int}/achievements")]
|
||||
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> Achievements(int id) =>
|
||||
Ok(await _gamification.GetUserAchievementsAsync(id));
|
||||
|
||||
/// <summary>Получить историю транзакций монет пользователя.</summary>
|
||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/transactions.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">История транзакций (пагинированная).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("{id:int}/transactions")]
|
||||
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
|
||||
{
|
||||
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
|
||||
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
|
||||
}
|
||||
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _gamification.GetTransactionsAsync(id, pagination));
|
||||
|
||||
/// <summary>Получить список всех пользователей с фильтрацией и пагинацией.</summary>
|
||||
/// <remarks>Только Admin.</remarks>
|
||||
/// <param name="filter">Параметры фильтрации (поиск, роль, активность) и пагинации.</param>
|
||||
/// <response code="200">Список пользователей (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResult<UserDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
|
||||
Ok(await _users.GetAllAsync(filter));
|
||||
|
||||
/// <summary>Изменить набор ролей пользователя.</summary>
|
||||
/// <remarks>Только Admin. Доступные роли: Student, Teacher, Admin.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="roles">Новый набор ролей пользователя.</param>
|
||||
/// <response code="204">Роли успешно изменены.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPatch("{id:int}/role")]
|
||||
public async Task<IActionResult> SetRole(int id, [FromBody] UserRole role)
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetRole(int id, [FromBody] IReadOnlyCollection<UserRole> roles)
|
||||
{
|
||||
await _users.SetRoleAsync(id, role);
|
||||
if (roles.Count == 0)
|
||||
return BadRequest("At least one role is required.");
|
||||
await _users.SetRolesAsync(id, roles);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Активировать или деактивировать аккаунт пользователя.</summary>
|
||||
/// <remarks>Только Admin. Деактивированный пользователь не может войти в систему.</remarks>
|
||||
/// <param name="id">ID пользователя.</param>
|
||||
/// <param name="isActive">true — активировать, false — деактивировать.</param>
|
||||
/// <response code="204">Статус успешно изменён.</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
/// <response code="404">Пользователь не найден.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPatch("{id:int}/active")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
|
||||
{
|
||||
await _users.SetActiveAsync(id, isActive);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.OpenApi;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace UniVerse.Api.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Swagger operation filter that:
|
||||
/// 1. Adds Bearer security requirement only to endpoints that actually require authentication.
|
||||
/// 2. Appends a "Required roles: ..." remark to the operation description when role restrictions exist.
|
||||
///
|
||||
/// This replaces the global AddSecurityRequirement approach so anonymous endpoints
|
||||
/// (auth/login, auth/refresh, auth/callback) don't show the lock icon in Swagger UI.
|
||||
/// </summary>
|
||||
public class AuthorizeOperationFilter : IOperationFilter
|
||||
{
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
// Collect [Authorize] and [AllowAnonymous] from both the controller and the action.
|
||||
var actionAttributes = context.MethodInfo.GetCustomAttributes(inherit: true);
|
||||
var controllerAttributes = context.MethodInfo.DeclaringType?
|
||||
.GetCustomAttributes(inherit: true) ?? [];
|
||||
|
||||
var allAttributes = actionAttributes.Concat(controllerAttributes).ToList();
|
||||
|
||||
var hasAllowAnonymous = allAttributes.OfType<AllowAnonymousAttribute>().Any();
|
||||
if (hasAllowAnonymous)
|
||||
return; // completely public — no lock icon
|
||||
|
||||
var authorizeAttributes = allAttributes.OfType<AuthorizeAttribute>().ToList();
|
||||
if (authorizeAttributes.Count == 0)
|
||||
return; // no [Authorize] at all — also public
|
||||
|
||||
// Collect all distinct roles across all [Authorize(Roles = "...")] attributes.
|
||||
var roles = authorizeAttributes
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Roles))
|
||||
.SelectMany(a => a.Roles!.Split(',', StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(r => r)
|
||||
.ToList();
|
||||
|
||||
// Append role information to the operation description.
|
||||
var roleInfo = roles.Count > 0
|
||||
? $"**Required roles:** {string.Join(", ", roles)}"
|
||||
: "**Required:** any authenticated user";
|
||||
|
||||
operation.Description = string.IsNullOrWhiteSpace(operation.Description)
|
||||
? roleInfo
|
||||
: $"{operation.Description}\n\n{roleInfo}";
|
||||
|
||||
operation.Responses ??= new OpenApiResponses();
|
||||
|
||||
// Add 401 / 403 responses if not already declared.
|
||||
if (!operation.Responses.ContainsKey("401"))
|
||||
{
|
||||
operation.Responses.Add("401", new OpenApiResponse
|
||||
{
|
||||
Description = "Unauthorized — JWT token missing or invalid"
|
||||
});
|
||||
}
|
||||
|
||||
if (roles.Count > 0 && !operation.Responses.ContainsKey("403"))
|
||||
{
|
||||
operation.Responses.Add("403", new OpenApiResponse
|
||||
{
|
||||
Description = $"Forbidden — requires role: {string.Join(" or ", roles)}"
|
||||
});
|
||||
}
|
||||
|
||||
// Add Bearer security requirement to this specific operation.
|
||||
// OpenAPI v2 (Microsoft.OpenApi 2.x) uses OpenApiSecuritySchemeReference
|
||||
// instead of OpenApiSecurityScheme with a Reference property.
|
||||
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", context.Document);
|
||||
|
||||
operation.Security ??= [];
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[bearerSchemeRef] = []
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class ExceptionHandlingMiddleware
|
||||
{
|
||||
var (statusCode, title) = exception switch
|
||||
{
|
||||
BadRequestException => ((int)HttpStatusCode.BadRequest, "Bad Request"),
|
||||
NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"),
|
||||
ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"),
|
||||
ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace UniVerse.Api.Options;
|
||||
|
||||
public class ReviewAnalysisOptions
|
||||
{
|
||||
public const string SectionName = "Llm:ReviewAnalysis";
|
||||
|
||||
public int MaxConcurrentProcessing { get; set; } = 1;
|
||||
}
|
||||
@@ -4,16 +4,29 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using Quartz;
|
||||
using Serilog;
|
||||
using UniVerse.Api.BackgroundServices;
|
||||
using UniVerse.Api.Filters;
|
||||
using UniVerse.Api.Middleware;
|
||||
using UniVerse.Api.Options;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.ExternalServices;
|
||||
using UniVerse.Infrastructure.Notifications;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var useAspire = builder.Configuration.GetValue<bool>("Aspire:Enabled");
|
||||
var isOpenApiGeneration = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Any(assembly => assembly.GetName().Name == "GetDocument.Insider");
|
||||
|
||||
if (useAspire)
|
||||
{
|
||||
builder.AddServiceDefaults();
|
||||
}
|
||||
|
||||
// --- Serilog ---
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
@@ -29,7 +42,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
npgsql =>
|
||||
{
|
||||
npgsql.EnableRetryOnFailure(3);
|
||||
npgsql.MigrationsAssembly("UniVerse.Infrastructure");
|
||||
npgsql.MigrationsAssembly("UniVerse.Infrastructure"); // Указывает EF Core, в какой сборке искать/хранить миграции.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +63,7 @@ builder.Services.AddAuthentication(options =>
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"] ?? "default-dev-secret-key-change-in-production-32chars!!"))
|
||||
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
@@ -77,10 +90,40 @@ builder.Services.AddScoped<ILocationService, LocationService>();
|
||||
builder.Services.AddScoped<ICourseService, CourseService>();
|
||||
builder.Services.AddScoped<ILectureService, LectureService>();
|
||||
builder.Services.AddScoped<IReviewService, ReviewService>();
|
||||
builder.Services.AddScoped<IReviewPromptService, ReviewPromptService>();
|
||||
builder.Services.AddScoped<IGamificationService, GamificationService>();
|
||||
builder.Services.AddScoped<IAchievementService, AchievementService>();
|
||||
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
|
||||
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
||||
builder.Services.AddScoped<IMicrosoftAuthClient, MicrosoftAuthClient>();
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
||||
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
||||
builder.Services.AddSingleton<ReviewAnalysisQueue>();
|
||||
builder.Services.AddSingleton<IReviewAnalysisQueue>(sp => sp.GetRequiredService<ReviewAnalysisQueue>());
|
||||
builder.Services.AddTransient<NotificationJob>();
|
||||
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
|
||||
builder.Services.AddOptions<ReviewAnalysisOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ReviewAnalysisOptions.SectionName))
|
||||
.Validate(options => options.MaxConcurrentProcessing >= 1,
|
||||
"Llm:ReviewAnalysis:MaxConcurrentProcessing must be greater than or equal to 1.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddQuartz();
|
||||
if (!isOpenApiGeneration)
|
||||
{
|
||||
builder.Services.AddQuartzHostedService(options =>
|
||||
{
|
||||
options.WaitForJobsToComplete = true;
|
||||
});
|
||||
}
|
||||
if (builder.Environment.IsDevelopment() && !isOpenApiGeneration)
|
||||
{
|
||||
builder.Services.AddQuartzDashboard(options =>
|
||||
{
|
||||
options.ReadOnly = true;
|
||||
});
|
||||
}
|
||||
|
||||
// --- HTTP Clients ---
|
||||
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
||||
@@ -96,7 +139,11 @@ builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
||||
});
|
||||
|
||||
// --- Background Services ---
|
||||
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
|
||||
if (!isOpenApiGeneration)
|
||||
{
|
||||
builder.Services.AddHostedService<ReviewAnalysisWorker>();
|
||||
builder.Services.AddHostedService<AchievementCatalogHostedService>();
|
||||
}
|
||||
|
||||
// --- Controllers ---
|
||||
builder.Services.AddControllers()
|
||||
@@ -114,9 +161,16 @@ builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
Title = "UniVerse API",
|
||||
Version = "v1",
|
||||
Description = "University schedule, reviews, and gamification platform"
|
||||
Description =
|
||||
"REST API веб-платформы UniVerse.\n\n" +
|
||||
"Аутентификация: JWT Bearer (получить через `POST /api/v1/auth/login/microsoft` или `POST /api/v1/auth/login/dev` в Development).",
|
||||
Contact = new OpenApiContact
|
||||
{
|
||||
Name = "UniVerse Dev"
|
||||
}
|
||||
});
|
||||
|
||||
// Bearer security scheme definition (used per-endpoint by AuthorizeOperationFilter)
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
@@ -124,34 +178,54 @@ builder.Services.AddSwaggerGen(options =>
|
||||
Scheme = "bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Enter your JWT token"
|
||||
Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`"
|
||||
});
|
||||
|
||||
options.AddSecurityRequirement(doc =>
|
||||
{
|
||||
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
|
||||
return new OpenApiSecurityRequirement
|
||||
{
|
||||
[bearerSchemeRef] = new List<string>()
|
||||
};
|
||||
});
|
||||
// Include XML doc comments generated from controller /// summaries
|
||||
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
if (File.Exists(xmlPath))
|
||||
options.IncludeXmlComments(xmlPath);
|
||||
|
||||
// Per-endpoint security requirement + role documentation (replaces global AddSecurityRequirement)
|
||||
options.OperationFilter<AuthorizeOperationFilter>();
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (useAspire)
|
||||
{
|
||||
app.MapDefaultEndpoints();
|
||||
}
|
||||
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "UniVerse API v1"));
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseSwagger(c =>
|
||||
{
|
||||
c.RouteTemplate = "api/docs/{documentName}/swagger.json";
|
||||
});
|
||||
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.RoutePrefix = "api/docs";
|
||||
c.SwaggerEndpoint("v1/swagger.json", "UniVerse API v1");
|
||||
});
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseAntiforgery();
|
||||
app.MapQuartzDashboard();
|
||||
}
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -4,7 +4,8 @@
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "api/docs",
|
||||
"applicationUrl": "http://localhost:5019",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
|
||||
@@ -7,26 +7,35 @@
|
||||
<RootNamespace>UniVerse.Api</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
|
||||
<OpenApiDocumentsDirectory>$(BaseIntermediateOutputPath)openapi</OpenApiDocumentsDirectory>
|
||||
<OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
|
||||
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
|
||||
<!-- Suppress warnings for public members without XML docs -->
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
|
||||
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
|
||||
<PackageReference Include="Quartz.Dashboard" Version="3.18.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\UniVerse.Application\UniVerse.Application.csproj" />
|
||||
<ProjectReference Include="..\UniVerse.Infrastructure\UniVerse.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,4 +44,14 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<Target
|
||||
Name="CopyGeneratedOpenApiDocument"
|
||||
AfterTargets="Build"
|
||||
Condition="Exists('$(OpenApiDocumentsDirectory)/openapi.json')">
|
||||
<Copy
|
||||
SourceFiles="$(OpenApiDocumentsDirectory)/openapi.json"
|
||||
DestinationFiles="$(MSBuildProjectDirectory)/openapi.json"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -4,5 +4,23 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=db;Port=5444;Database=universe;Username=universe;Password=pass"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
|
||||
"Issuer": "UniVerse",
|
||||
"Audience": "UniVerse",
|
||||
"AccessTokenExpirationMinutes": "30",
|
||||
"RefreshTokenExpirationDays": "30"
|
||||
},
|
||||
"AzureAd": {
|
||||
"Instance": "https://login.microsoftonline.com/",
|
||||
"TenantId": "sfedu.ru",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"Domain": "sfedu.onmicrosoft.com",
|
||||
"CallbackPath": "/signin-oidc"
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,6 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=universe;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
|
||||
"Issuer": "UniVerse",
|
||||
"Audience": "UniVerse",
|
||||
"AccessTokenExpirationMinutes": "30",
|
||||
"RefreshTokenExpirationDays": "30"
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"http://localhost:5173",
|
||||
@@ -26,22 +16,33 @@
|
||||
"Llm": {
|
||||
"BaseUrl": "https://api.openai.com/v1/",
|
||||
"ApiKey": "",
|
||||
"Model": "gpt-4o-mini"
|
||||
"Model": "gpt-4o-mini",
|
||||
"ReviewAnalysis": {
|
||||
"MaxConcurrentProcessing": 1
|
||||
}
|
||||
},
|
||||
"ModeusApi": {
|
||||
"BaseUrl": "https://schedule.rdcenter.ru",
|
||||
"ApiKey": ""
|
||||
},
|
||||
"Gamification": {
|
||||
"XpThresholds": [0, 100, 300, 600, 1000, 1500, 2500, 4000]
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"System": "Warning"
|
||||
"Microsoft": "Information",
|
||||
"System": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Email": {
|
||||
"Smtp": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"EnableSsl": true,
|
||||
"UserName": "",
|
||||
"Password": "",
|
||||
"FromAddress": "no-reply@universe.local",
|
||||
"FromName": "UniVerse"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var api = builder
|
||||
.AddProject<Projects.UniVerse_Api>("universe-api")
|
||||
.WithEnvironment("Aspire__Enabled", "true");
|
||||
|
||||
// Запуск фронтенда (Vue + Vite) в dev-режиме вместе.
|
||||
// Требования: установлен pnpm (или включён corepack), зависимости фронта установлены.
|
||||
builder
|
||||
.AddExecutable("universe-frontend", "pnpm", workingDirectory: "../../frontend")
|
||||
.WithArgs("run", "dev:aspire")
|
||||
.WithHttpEndpoint(targetPort: 5173, port: 5173, name: "http", isProxied: false)
|
||||
// Используется в vite.config.ts для server.proxy['/api'].target
|
||||
.WithEnvironment("VITE_API_PROXY_TARGET", api.GetEndpoint("http"));
|
||||
|
||||
builder.Build().Run();
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://127.0.0.1:17156;http://127.0.0.1:15060",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"HTTP_PROXY": "",
|
||||
"HTTPS_PROXY": "",
|
||||
"ALL_PROXY": "",
|
||||
"http_proxy": "",
|
||||
"https_proxy": "",
|
||||
"all_proxy": "",
|
||||
"NO_PROXY": "localhost,127.0.0.1,::1",
|
||||
"no_proxy": "localhost,127.0.0.1,::1",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://127.0.0.1:21010",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://127.0.0.1:23046",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://127.0.0.1:22274"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://127.0.0.1:15060",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
|
||||
"HTTP_PROXY": "",
|
||||
"HTTPS_PROXY": "",
|
||||
"ALL_PROXY": "",
|
||||
"http_proxy": "",
|
||||
"https_proxy": "",
|
||||
"all_proxy": "",
|
||||
"NO_PROXY": "localhost,127.0.0.1,::1",
|
||||
"no_proxy": "localhost,127.0.0.1,::1",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://127.0.0.1:19138",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://127.0.0.1:18238",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://127.0.0.1:20274"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>fb90d29a-6c48-471b-b19f-d2f431a5ef38</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "UniVerse.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ namespace UniVerse.Application.DTOs.Auth;
|
||||
public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User);
|
||||
public record AuthResult(AuthResponse Response, string RefreshToken);
|
||||
|
||||
public record UserAuthDto(int Id, string Email, string? DisplayName, UserRole Role);
|
||||
public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList<UserRole> Roles);
|
||||
|
||||
public record LoginMicrosoftRequest(string AuthorizationCode);
|
||||
public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null);
|
||||
|
||||
public record DevLoginRequest(string Email, string? DisplayName = null, UserRole Role = UserRole.Student);
|
||||
public record DevLoginRequest(string Email, string? DisplayName = null, IReadOnlyList<UserRole>? Roles = null);
|
||||
|
||||
@@ -11,3 +11,8 @@ public record CoinTransactionDto(
|
||||
string? Description,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record LevelProgressDto(
|
||||
int CurrentLevelXp,
|
||||
int? NextLevelXp
|
||||
);
|
||||
|
||||
@@ -19,7 +19,8 @@ public record LectureDto(
|
||||
int MaxEnrollments,
|
||||
int EnrollmentsCount,
|
||||
string? OnlineUrl,
|
||||
DateTime CreatedAt
|
||||
DateTime CreatedAt,
|
||||
bool IsEnrolled = false
|
||||
);
|
||||
|
||||
public record LectureDetailDto(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace UniVerse.Application.DTOs.Notifications;
|
||||
|
||||
public static class NotificationChannels
|
||||
{
|
||||
public const string Email = "email";
|
||||
}
|
||||
|
||||
public record NotificationMessage(
|
||||
string Channel,
|
||||
string Recipient,
|
||||
string Subject,
|
||||
string Body,
|
||||
string? RecipientName = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
public record SendNotificationRequest(
|
||||
string Channel,
|
||||
string Recipient,
|
||||
string Subject,
|
||||
string Body,
|
||||
string? RecipientName = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
public record ScheduleNotificationRequest(
|
||||
string Channel,
|
||||
string Recipient,
|
||||
string Subject,
|
||||
string Body,
|
||||
DateTimeOffset SendAt,
|
||||
string? RecipientName = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt);
|
||||
|
||||
public record UserNotificationDto(
|
||||
int Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string Body,
|
||||
bool IsRead,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
@@ -15,9 +15,20 @@ public record ReviewDto(
|
||||
double? QualityScore,
|
||||
bool? IsInformative,
|
||||
string[]? LlmTags,
|
||||
string? LlmRawOutput,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text);
|
||||
|
||||
public record UpdateReviewRequest(ReviewRating Rating, string? Text);
|
||||
|
||||
public record ReviewFilterRequest(
|
||||
ReviewLlmStatus? LlmStatus,
|
||||
int Page = 1,
|
||||
int PageSize = 20
|
||||
);
|
||||
|
||||
public record ReviewPromptDto(string Prompt, DateTime? UpdatedAt);
|
||||
|
||||
public record UpdateReviewPromptRequest(string Prompt);
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
namespace UniVerse.Application.DTOs.Sync;
|
||||
|
||||
public record SyncScheduleRequest(
|
||||
string? SpecialtyCode,
|
||||
IReadOnlyList<string>? SpecialtyCode,
|
||||
DateTime? TimeMin,
|
||||
DateTime? TimeMax,
|
||||
string? TypeId
|
||||
IReadOnlyList<string>? TypeId,
|
||||
int? Size = null,
|
||||
IReadOnlyList<string>? RoomId = null,
|
||||
IReadOnlyList<string>? AttendeePersonId = null,
|
||||
IReadOnlyList<string>? CourseUnitRealizationId = null,
|
||||
IReadOnlyList<string>? CycleRealizationId = null,
|
||||
IReadOnlyList<int>? LearningStartYear = null,
|
||||
IReadOnlyList<string>? ProfileName = null,
|
||||
IReadOnlyList<string>? CurriculumId = null
|
||||
);
|
||||
|
||||
public record SyncResultDto(int Created, int Updated, int Skipped, string? Error);
|
||||
public record SyncResultDto(
|
||||
int Created,
|
||||
int Updated,
|
||||
int Skipped,
|
||||
string? Error,
|
||||
IReadOnlyList<string>? Details = null
|
||||
);
|
||||
|
||||
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ public record UserDto(
|
||||
string Email,
|
||||
string? DisplayName,
|
||||
string? AvatarUrl,
|
||||
UserRole Role,
|
||||
IReadOnlyList<UserRole> Roles,
|
||||
bool IsActive,
|
||||
int Xp,
|
||||
int Coins,
|
||||
@@ -15,6 +15,18 @@ public record UserDto(
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record CurrentUserDto(
|
||||
int Id,
|
||||
string Email,
|
||||
string? DisplayName,
|
||||
string? AvatarUrl,
|
||||
IReadOnlyList<UserRole> Roles,
|
||||
int Xp,
|
||||
int Coins,
|
||||
int Level,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record UserStatsDto(
|
||||
int TotalLectures,
|
||||
int AttendedLectures,
|
||||
@@ -22,9 +34,16 @@ public record UserStatsDto(
|
||||
int Xp,
|
||||
int Coins,
|
||||
int Level,
|
||||
int AchievementsCount
|
||||
int AchievementsCount,
|
||||
int CurrentLevelXp,
|
||||
int? NextLevelXp,
|
||||
int ActiveEnrollments,
|
||||
int EnrollmentSlotLimit,
|
||||
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
|
||||
);
|
||||
|
||||
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
||||
|
||||
public record UpdateUserRequest(
|
||||
string? DisplayName,
|
||||
string? AvatarUrl
|
||||
|
||||
@@ -5,9 +5,9 @@ namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode);
|
||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
|
||||
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null);
|
||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
|
||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||
Task<UserDto> GetCurrentUserAsync(int userId);
|
||||
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ public interface IGamificationService
|
||||
Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
|
||||
int? reviewId = null, int? achievementId = null, string? description = null);
|
||||
Task CheckAndAwardAchievementsAsync(int userId);
|
||||
int CalculateLevel(int xp);
|
||||
Task<int> CalculateLevelAsync(int xp);
|
||||
Task<LevelProgressDto> GetLevelProgressAsync(int xp);
|
||||
Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId);
|
||||
Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface ILectureService
|
||||
{
|
||||
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter);
|
||||
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null);
|
||||
Task<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null);
|
||||
Task<LectureDto> CreateAsync(CreateLectureRequest request);
|
||||
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request);
|
||||
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request, int currentUserId, bool isAdmin = false);
|
||||
Task DeleteAsync(int id);
|
||||
Task EnrollAsync(int lectureId, int userId);
|
||||
Task UnenrollAsync(int lectureId, int userId);
|
||||
Task MarkAttendanceAsync(int lectureId, int userId, bool attended);
|
||||
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination);
|
||||
Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false);
|
||||
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,4 @@ namespace UniVerse.Application.Interfaces;
|
||||
public interface ILlmAnalysisService
|
||||
{
|
||||
Task AnalyzeReviewAsync(int reviewId);
|
||||
Task ProcessPendingReviewsAsync();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ public record LlmReviewAnalysis(
|
||||
double QualityScore,
|
||||
string Sentiment,
|
||||
string[] Tags,
|
||||
bool IsInformative
|
||||
bool IsInformative,
|
||||
string RawOutput
|
||||
);
|
||||
|
||||
public interface ILlmClient
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface IMicrosoftAuthClient
|
||||
{
|
||||
Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
|
||||
string authorizationCode,
|
||||
string redirectUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public record MicrosoftTokenResult(string IdToken);
|
||||
@@ -0,0 +1,9 @@
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
|
||||
namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface INotificationProvider
|
||||
{
|
||||
string Channel { get; }
|
||||
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
|
||||
namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface INotificationScheduler
|
||||
{
|
||||
Task<ScheduledNotificationResponse> ScheduleAsync(
|
||||
NotificationMessage message,
|
||||
DateTimeOffset sendAt,
|
||||
string? jobId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task CancelAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
|
||||
namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
|
||||
Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default);
|
||||
Task<UserNotificationDto> CreateUserNotificationAsync(int userId, string type, string title, string body, CancellationToken cancellationToken = default);
|
||||
Task<PagedResult<UserNotificationDto>> GetUserNotificationsAsync(int userId, PaginationRequest pagination, CancellationToken cancellationToken = default);
|
||||
Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface IReviewAnalysisQueue
|
||||
{
|
||||
Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using UniVerse.Application.DTOs.Reviews;
|
||||
|
||||
namespace UniVerse.Application.Interfaces;
|
||||
|
||||
public interface IReviewPromptService
|
||||
{
|
||||
Task<ReviewPromptDto> GetAsync();
|
||||
Task<ReviewPromptDto> UpdateAsync(UpdateReviewPromptRequest request);
|
||||
}
|
||||
@@ -9,8 +9,8 @@ public interface IReviewService
|
||||
Task<ReviewDto> GetByIdAsync(int id);
|
||||
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
|
||||
Task DeleteAsync(int id, int userId, bool isAdmin = false);
|
||||
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
|
||||
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination, int? currentUserId = null, bool isAdmin = false);
|
||||
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
|
||||
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
|
||||
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
|
||||
Task ReanalyzeAsync(int id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UniVerse.Application.DTOs.Sync;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace UniVerse.Application.Interfaces;
|
||||
|
||||
@@ -15,11 +16,102 @@ public interface IModeusApiClient
|
||||
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
|
||||
Task<ModeusRoomsResponse> SearchRoomsAsync();
|
||||
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
|
||||
Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
// Modeus API response models
|
||||
public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId);
|
||||
public record ModeusEventsResponse(List<ModeusEvent> Events);
|
||||
public record ModeusRoom(string Id, string Name, string? Building);
|
||||
public record ModeusRoomsResponse(List<ModeusRoom> Rooms);
|
||||
public class ModeusEvent
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? NameShort { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TypeId { get; init; }
|
||||
public DateTime StartsAt { get; init; }
|
||||
public DateTime EndsAt { get; init; }
|
||||
|
||||
[JsonPropertyName("_links")]
|
||||
public ModeusEventLinks? Links { get; init; }
|
||||
}
|
||||
|
||||
public class ModeusEventLinks
|
||||
{
|
||||
[JsonPropertyName("course-unit-realization")]
|
||||
public ModeusHrefLink? CourseUnitRealization { get; init; }
|
||||
}
|
||||
|
||||
public class ModeusEventsResponse
|
||||
{
|
||||
[JsonPropertyName("_embedded")]
|
||||
public ModeusEventsEmbedded? Embedded { get; init; }
|
||||
public List<ModeusEvent>? Events { get; init; }
|
||||
public ModeusPage? Page { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<ModeusEvent> EventItems => Embedded?.Events ?? Events ?? [];
|
||||
}
|
||||
public class ModeusEventsEmbedded
|
||||
{
|
||||
public List<ModeusEvent>? Events { get; init; }
|
||||
|
||||
[JsonPropertyName("course-unit-realizations")]
|
||||
public List<ModeusCourseUnitRealization>? CourseUnitRealizations { get; init; }
|
||||
|
||||
[JsonPropertyName("event-rooms")]
|
||||
public List<ModeusEventRoom>? EventRooms { get; init; }
|
||||
|
||||
[JsonPropertyName("event-teams")]
|
||||
public List<ModeusEventTeam>? EventTeams { get; init; }
|
||||
|
||||
[JsonPropertyName("event-attendees")]
|
||||
public List<ModeusEventAttendee>? EventAttendees { get; init; }
|
||||
|
||||
public List<ModeusPerson>? Persons { get; init; }
|
||||
|
||||
public List<ModeusRoom>? Rooms { get; init; }
|
||||
}
|
||||
public record ModeusHrefLink(string? Href);
|
||||
public record ModeusCourseUnitRealization(string Id, string Name, string? NameShort);
|
||||
public class ModeusEventRoom
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("_links")]
|
||||
public ModeusEventRoomLinks? Links { get; init; }
|
||||
}
|
||||
public class ModeusEventRoomLinks
|
||||
{
|
||||
public ModeusHrefLink? Event { get; init; }
|
||||
public ModeusHrefLink? Room { get; init; }
|
||||
}
|
||||
public record ModeusEventTeam(string EventId, int? Size);
|
||||
public class ModeusEventAttendee
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string? RoleId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
|
||||
[JsonPropertyName("_links")]
|
||||
public ModeusEventAttendeeLinks? Links { get; init; }
|
||||
}
|
||||
public class ModeusEventAttendeeLinks
|
||||
{
|
||||
public ModeusHrefLink? Event { get; init; }
|
||||
public ModeusHrefLink? Person { get; init; }
|
||||
}
|
||||
public record ModeusPerson(string Id, string? LastName, string? FirstName, string? MiddleName, string? FullName);
|
||||
public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address);
|
||||
public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity);
|
||||
public record ModeusRoomsEmbedded(List<ModeusRoom>? Rooms);
|
||||
public record ModeusPage(int Size, int TotalElements, int TotalPages, int Number);
|
||||
public class ModeusRoomsResponse
|
||||
{
|
||||
[JsonPropertyName("_embedded")]
|
||||
public ModeusRoomsEmbedded? Embedded { get; init; }
|
||||
public ModeusPage? Page { get; init; }
|
||||
public List<ModeusRoom>? Rooms { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<ModeusRoom> RoomItems => Embedded?.Rooms ?? Rooms ?? [];
|
||||
}
|
||||
public record ModeusEmployee(string? Id, string FullName, string? Department);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UniVerse.Application.DTOs.Common;
|
||||
using UniVerse.Application.DTOs.Lectures;
|
||||
using UniVerse.Application.DTOs.Users;
|
||||
using UniVerse.Domain.Enums;
|
||||
|
||||
@@ -9,7 +10,8 @@ public interface IUserService
|
||||
Task<UserDto> GetByIdAsync(int id);
|
||||
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
||||
Task<UserStatsDto> GetStatsAsync(int id);
|
||||
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
|
||||
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
||||
Task SetRoleAsync(int id, UserRole role);
|
||||
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
||||
Task SetActiveAsync(int id, bool isActive);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ public static class MappingExtensions
|
||||
// --- User ---
|
||||
public static UserDto ToDto(this User user, int level) => new(
|
||||
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
||||
user.Role, user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
|
||||
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
|
||||
);
|
||||
|
||||
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
|
||||
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
||||
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
|
||||
);
|
||||
|
||||
public static UserAuthDto ToAuthDto(this User user) => new(
|
||||
user.Id, user.Email, user.DisplayName, user.Role
|
||||
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
|
||||
);
|
||||
|
||||
// --- Tag ---
|
||||
@@ -46,14 +51,14 @@ public static class MappingExtensions
|
||||
);
|
||||
|
||||
// --- Lecture ---
|
||||
public static LectureDto ToDto(this Lecture lecture) => new(
|
||||
public static LectureDto ToDto(this Lecture lecture, bool isEnrolled = false) => new(
|
||||
lecture.Id, lecture.CourseId, lecture.Course?.Name ?? "",
|
||||
lecture.TeacherId, lecture.Teacher?.DisplayName,
|
||||
lecture.LocationId, lecture.Location?.Name,
|
||||
lecture.Title, lecture.Description, lecture.Format,
|
||||
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
||||
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
||||
lecture.OnlineUrl, lecture.CreatedAt
|
||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||
);
|
||||
|
||||
public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new(
|
||||
@@ -79,7 +84,7 @@ public static class MappingExtensions
|
||||
review.UserId, review.User?.DisplayName,
|
||||
review.Rating, review.Text, review.LlmStatus,
|
||||
review.Sentiment, review.QualityScore, review.IsInformative,
|
||||
review.LlmTags, review.CreatedAt
|
||||
review.LlmTags, review.LlmRawOutput, review.CreatedAt
|
||||
);
|
||||
|
||||
// --- Achievement ---
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace UniVerse.Application.Prompts;
|
||||
|
||||
public static class ReviewPromptTemplate
|
||||
{
|
||||
public const string LectureContextPlaceholder = "{lectureContext}";
|
||||
public const string ReviewTextPlaceholder = "{reviewText}";
|
||||
|
||||
public const string Default = """
|
||||
Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
|
||||
- quality_score: число от 0 до 1, указывающее на качество отзыва;
|
||||
- sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
|
||||
- tags: массив соответствующих тематических тегов;
|
||||
- is_informative: логическое значение, указывающее, является ли отзыв информативным.
|
||||
|
||||
Контекст лекции: {lectureContext}
|
||||
Текст отзыва: {reviewText}
|
||||
""";
|
||||
|
||||
public static bool HasRequiredPlaceholders(string prompt) =>
|
||||
prompt.Contains(LectureContextPlaceholder, StringComparison.Ordinal) &&
|
||||
prompt.Contains(ReviewTextPlaceholder, StringComparison.Ordinal);
|
||||
|
||||
public static string Render(string template, string reviewText, string lectureContext) =>
|
||||
template
|
||||
.Replace(LectureContextPlaceholder, lectureContext)
|
||||
.Replace(ReviewTextPlaceholder, reviewText);
|
||||
}
|
||||
@@ -8,12 +8,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace UniVerse.Domain.Entities;
|
||||
|
||||
public class LevelThreshold
|
||||
{
|
||||
public int Level { get; set; }
|
||||
public int RequiredXp { get; set; }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class Review
|
||||
public double? QualityScore { get; set; }
|
||||
public bool? IsInformative { get; set; }
|
||||
public string[]? LlmTags { get; set; }
|
||||
public string? LlmRawOutput { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace UniVerse.Domain.Entities;
|
||||
|
||||
public class ReviewPromptSetting
|
||||
{
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int Id { get; set; } = SingletonId;
|
||||
public string Prompt { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -8,7 +8,6 @@ public class User
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
public UserRole Role { get; set; } = UserRole.Student;
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? MicrosoftId { get; set; }
|
||||
public int Xp { get; set; }
|
||||
@@ -19,9 +18,11 @@ public class User
|
||||
// Navigation properties
|
||||
public StudentProfile? StudentProfile { get; set; }
|
||||
public TeacherProfile? TeacherProfile { get; set; }
|
||||
public ICollection<UserRoleAssignment> Roles { get; set; } = new List<UserRoleAssignment>();
|
||||
public ICollection<LectureEnrollment> Enrollments { get; set; } = new List<LectureEnrollment>();
|
||||
public ICollection<Review> Reviews { get; set; } = new List<Review>();
|
||||
public ICollection<UserAchievement> UserAchievements { get; set; } = new List<UserAchievement>();
|
||||
public ICollection<CoinTransaction> CoinTransactions { get; set; } = new List<CoinTransaction>();
|
||||
public ICollection<UserNotification> Notifications { get; set; } = new List<UserNotification>();
|
||||
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace UniVerse.Domain.Entities;
|
||||
|
||||
public class UserNotification
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Body { get; set; } = string.Empty;
|
||||
public bool IsRead { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using UniVerse.Domain.Enums;
|
||||
|
||||
namespace UniVerse.Domain.Entities;
|
||||
|
||||
public class UserRoleAssignment
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public UserRole Role { get; set; }
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace UniVerse.Domain.Exceptions;
|
||||
|
||||
public class BadRequestException : Exception
|
||||
{
|
||||
public BadRequestException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace UniVerse.Domain.Services;
|
||||
|
||||
public static class EnrollmentSlotPolicy
|
||||
{
|
||||
private static readonly IReadOnlyList<EnrollmentSlotRule> SlotRules =
|
||||
[
|
||||
new(1, 3),
|
||||
new(3, 5),
|
||||
new(4, 7)
|
||||
];
|
||||
|
||||
public static IReadOnlyList<EnrollmentSlotRule> Rules => SlotRules;
|
||||
|
||||
public static int GetLimitForLevel(int level) =>
|
||||
SlotRules
|
||||
.Where(rule => rule.Level <= level)
|
||||
.OrderBy(rule => rule.Level)
|
||||
.LastOrDefault()?.Slots ?? SlotRules[0].Slots;
|
||||
}
|
||||
|
||||
public sealed record EnrollmentSlotRule(int Level, int Slots);
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using UniVerse.Domain.Entities;
|
||||
|
||||
namespace UniVerse.Infrastructure.Data;
|
||||
|
||||
public static class AchievementCatalogSeeder
|
||||
{
|
||||
private static readonly IReadOnlyList<AchievementSeed> Catalog =
|
||||
[
|
||||
new(1001, "Добро пожаловать в UniVerse", "Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.", "sparkles", 10, "first_activity:1"),
|
||||
new(1002, "Первый шаг", "Посетить первую открытую лекцию.", "book-2", 10, "lectures_attended:1"),
|
||||
new(1003, "Вошел во вкус", "Посетить 3 открытые лекции.", "books", 20, "lectures_attended:3"),
|
||||
new(1004, "Постоянный слушатель", "Посетить 5 открытых лекций.", "calendar-event", 35, "lectures_attended:5"),
|
||||
new(1005, "Академический марафон", "Посетить 10 открытых лекций.", "stopwatch", 60, "lectures_attended:10"),
|
||||
new(1006, "Грандмастер лекций", "Посетить 25 открытых лекций.", "trophy", 120, "lectures_attended:25"),
|
||||
new(1007, "Первый отзыв", "Оставить первый отзыв о посещенной лекции.", "message-circle", 10, "reviews_written:1"),
|
||||
new(1008, "Голос аудитории", "Оставить 3 отзыва о лекциях.", "thumb-up", 25, "reviews_written:3"),
|
||||
new(1009, "Рецензент", "Оставить 10 отзывов о лекциях.", "clipboard-list", 70, "reviews_written:10"),
|
||||
new(1010, "Голос перемен", "Оставить 25 отзывов о лекциях.", "chart-line", 150, "reviews_written:25"),
|
||||
new(1011, "Смелый выбор", "Записаться на первую открытую лекцию.", "calendar", 5, "lectures_registered:1"),
|
||||
new(1012, "План на неделю", "Иметь 3 активные записи на будущие лекции.", "calendar-event", 15, "active_registrations:3"),
|
||||
new(1013, "Полный календарь", "Иметь 5 активных записей на будущие лекции.", "alarm", 30, "active_registrations:5"),
|
||||
new(1014, "Серия интереса", "Посещать открытые лекции 3 недели подряд.", "star", 50, "attendance_streak_weeks:3"),
|
||||
new(1015, "Учебный месяц", "Посещать открытые лекции 4 недели подряд.", "sparkles", 80, "attendance_streak_weeks:4"),
|
||||
new(1016, "Без пропусков", "Посетить 5 лекций, на которые была оформлена запись.", "circle-check", 40, "attended_registered:5"),
|
||||
new(1017, "Надежный участник", "Посетить 10 лекций, на которые была оформлена запись.", "shield", 75, "attended_registered:10"),
|
||||
new(1018, "Капитал знаний", "Получить 500 монет за активность на платформе.", "coin", 80, "coins_earned:500"),
|
||||
new(1019, "Новый уровень", "Достигнуть 2 уровня.", "star", 25, "level_reached:2"),
|
||||
new(1020, "Уверенный рост", "Достигнуть 5 уровня.", "chart-bar", 100, "level_reached:5"),
|
||||
new(1021, "Профиль заполнен", "Заполнить имя и аватар в профиле.", "user", 10, "profile_completed:1")
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> LegacyConditions = new Dictionary<string, string>
|
||||
{
|
||||
["reviews_1"] = "reviews_written:1",
|
||||
["reviews_5"] = "reviews_written:5",
|
||||
["reviews_10"] = "reviews_written:10",
|
||||
["attended_5"] = "lectures_attended:5",
|
||||
["attended_10"] = "lectures_attended:10"
|
||||
};
|
||||
|
||||
public static async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var legacyConditionKeys = LegacyConditions.Keys.ToArray();
|
||||
|
||||
var legacyAchievements = await db.Achievements
|
||||
.Where(a => a.Condition != null && legacyConditionKeys.Contains(a.Condition))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var achievement in legacyAchievements)
|
||||
achievement.Condition = LegacyConditions[achievement.Condition!];
|
||||
|
||||
foreach (var seed in Catalog)
|
||||
{
|
||||
var achievement = await db.Achievements.FindAsync([seed.Id], cancellationToken);
|
||||
if (achievement == null)
|
||||
{
|
||||
db.Achievements.Add(new Achievement
|
||||
{
|
||||
Id = seed.Id,
|
||||
Name = seed.Name,
|
||||
Description = seed.Description,
|
||||
IconUrl = seed.IconUrl,
|
||||
XpReward = 0,
|
||||
CoinReward = seed.CoinReward,
|
||||
Condition = seed.Condition
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
achievement.Name = seed.Name;
|
||||
achievement.Description = seed.Description;
|
||||
achievement.IconUrl = seed.IconUrl;
|
||||
achievement.XpReward = 0;
|
||||
achievement.CoinReward = seed.CoinReward;
|
||||
achievement.Condition = seed.Condition;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record AchievementSeed(
|
||||
int Id,
|
||||
string Name,
|
||||
string Description,
|
||||
string IconUrl,
|
||||
int CoinReward,
|
||||
string Condition);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public class AppDbContext : DbContext
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<User> Users { get; set; } = null!;
|
||||
public DbSet<UserRoleAssignment> UserRoles { get; set; } = null!;
|
||||
public DbSet<StudentProfile> StudentProfiles { get; set; } = null!;
|
||||
public DbSet<TeacherProfile> TeacherProfiles { get; set; } = null!;
|
||||
public DbSet<Course> Courses { get; set; } = null!;
|
||||
@@ -19,9 +20,12 @@ public class AppDbContext : DbContext
|
||||
public DbSet<CourseTag> CourseTags { get; set; } = null!;
|
||||
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
|
||||
public DbSet<Review> Reviews { get; set; } = null!;
|
||||
public DbSet<ReviewPromptSetting> ReviewPromptSettings { get; set; } = null!;
|
||||
public DbSet<Achievement> Achievements { get; set; } = null!;
|
||||
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
|
||||
public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!;
|
||||
public DbSet<LevelThreshold> LevelThresholds { get; set; } = null!;
|
||||
public DbSet<UserNotification> UserNotifications { get; set; } = null!;
|
||||
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
||||
|
||||
static AppDbContext()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using UniVerse.Domain.Entities;
|
||||
|
||||
namespace UniVerse.Infrastructure.Data.Configurations;
|
||||
|
||||
public class LevelThresholdConfiguration : IEntityTypeConfiguration<LevelThreshold>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LevelThreshold> builder)
|
||||
{
|
||||
builder.ToTable("level_thresholds", table =>
|
||||
{
|
||||
table.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
|
||||
table.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
|
||||
});
|
||||
|
||||
builder.HasKey(t => t.Level);
|
||||
builder.Property(t => t.Level).HasColumnName("level").ValueGeneratedNever();
|
||||
builder.Property(t => t.RequiredXp).HasColumnName("required_xp").IsRequired();
|
||||
builder.HasIndex(t => t.RequiredXp).IsUnique();
|
||||
|
||||
builder.HasData(
|
||||
new LevelThreshold { Level = 1, RequiredXp = 0 },
|
||||
new LevelThreshold { Level = 2, RequiredXp = 100 },
|
||||
new LevelThreshold { Level = 3, RequiredXp = 300 },
|
||||
new LevelThreshold { Level = 4, RequiredXp = 600 },
|
||||
new LevelThreshold { Level = 5, RequiredXp = 1000 },
|
||||
new LevelThreshold { Level = 6, RequiredXp = 1500 },
|
||||
new LevelThreshold { Level = 7, RequiredXp = 2500 },
|
||||
new LevelThreshold { Level = 8, RequiredXp = 4000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public class ReviewConfiguration : IEntityTypeConfiguration<Review>
|
||||
builder.Property(r => r.QualityScore).HasColumnName("quality_score");
|
||||
builder.Property(r => r.IsInformative).HasColumnName("is_informative");
|
||||
builder.Property(r => r.LlmTags).HasColumnName("llm_tags");
|
||||
builder.Property(r => r.LlmRawOutput).HasColumnName("llm_raw_output");
|
||||
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using UniVerse.Domain.Entities;
|
||||
|
||||
namespace UniVerse.Infrastructure.Data.Configurations;
|
||||
|
||||
public class ReviewPromptSettingConfiguration : IEntityTypeConfiguration<ReviewPromptSetting>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReviewPromptSetting> builder)
|
||||
{
|
||||
builder.ToTable("review_prompt_settings");
|
||||
|
||||
builder.HasKey(setting => setting.Id);
|
||||
builder.Property(setting => setting.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
builder.Property(setting => setting.Prompt)
|
||||
.HasColumnName("prompt")
|
||||
.IsRequired();
|
||||
builder.Property(setting => setting.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
builder.Property(setting => setting.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,6 @@ public class TeacherProfileConfiguration : IEntityTypeConfiguration<TeacherProfi
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(t => t.UserId).IsUnique();
|
||||
builder.HasIndex(t => t.ModeusId).IsUnique().HasFilter("modeus_id IS NOT NULL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
builder.Property(u => u.Email).HasColumnName("email").HasMaxLength(255).IsRequired();
|
||||
builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(255);
|
||||
builder.Property(u => u.AvatarUrl).HasColumnName("avatar_url").HasMaxLength(500);
|
||||
builder.Property(u => u.Role).HasColumnName("role");
|
||||
builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true);
|
||||
builder.Property(u => u.MicrosoftId).HasColumnName("microsoft_id").HasMaxLength(255);
|
||||
builder.Property(u => u.Xp).HasColumnName("xp").HasDefaultValue(0);
|
||||
@@ -25,5 +24,10 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
|
||||
builder.HasIndex(u => u.Email).IsUnique();
|
||||
builder.HasIndex(u => u.MicrosoftId).IsUnique().HasFilter("microsoft_id IS NOT NULL");
|
||||
|
||||
builder.HasMany(u => u.Roles)
|
||||
.WithOne(ur => ur.User)
|
||||
.HasForeignKey(ur => ur.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using UniVerse.Domain.Entities;
|
||||
|
||||
namespace UniVerse.Infrastructure.Data.Configurations;
|
||||
|
||||
public class UserNotificationConfiguration : IEntityTypeConfiguration<UserNotification>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserNotification> builder)
|
||||
{
|
||||
builder.ToTable("user_notifications");
|
||||
|
||||
builder.HasKey(n => n.Id);
|
||||
builder.Property(n => n.Id).HasColumnName("id");
|
||||
builder.Property(n => n.UserId).HasColumnName("user_id");
|
||||
builder.Property(n => n.Type).HasColumnName("type").HasMaxLength(50).IsRequired();
|
||||
builder.Property(n => n.Title).HasColumnName("title").HasMaxLength(255).IsRequired();
|
||||
builder.Property(n => n.Body).HasColumnName("body").HasMaxLength(1000).IsRequired();
|
||||
builder.Property(n => n.IsRead).HasColumnName("is_read").HasDefaultValue(false);
|
||||
builder.Property(n => n.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||
|
||||
builder.HasOne(n => n.User)
|
||||
.WithMany(u => u.Notifications)
|
||||
.HasForeignKey(n => n.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(n => new { n.UserId, n.CreatedAt });
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using UniVerse.Domain.Entities;
|
||||
|
||||
namespace UniVerse.Infrastructure.Data.Configurations;
|
||||
|
||||
public class UserRoleAssignmentConfiguration : IEntityTypeConfiguration<UserRoleAssignment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserRoleAssignment> builder)
|
||||
{
|
||||
builder.ToTable("user_roles");
|
||||
|
||||
builder.HasKey(ur => new { ur.UserId, ur.Role });
|
||||
builder.Property(ur => ur.UserId).HasColumnName("user_id");
|
||||
builder.Property(ur => ur.Role).HasColumnName("role");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Application.Prompts;
|
||||
|
||||
namespace UniVerse.Infrastructure.ExternalServices;
|
||||
|
||||
@@ -11,25 +12,25 @@ public class LlmClient : ILlmClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IReviewPromptService _reviewPrompts;
|
||||
private readonly ILogger<LlmClient> _logger;
|
||||
|
||||
public LlmClient(HttpClient http, IConfiguration config, ILogger<LlmClient> logger)
|
||||
public LlmClient(
|
||||
HttpClient http,
|
||||
IConfiguration config,
|
||||
IReviewPromptService reviewPrompts,
|
||||
ILogger<LlmClient> logger)
|
||||
{
|
||||
_http = http; _config = config; _logger = logger;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_reviewPrompts = reviewPrompts;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<LlmReviewAnalysis> AnalyzeReviewAsync(string reviewText, string lectureContext)
|
||||
{
|
||||
var prompt = $"""
|
||||
Analyze the following student review of a lecture. Return a JSON object with:
|
||||
- quality_score: float 0-1 indicating review quality
|
||||
- sentiment: "Positive", "Neutral", or "Negative"
|
||||
- tags: array of relevant topic tags
|
||||
- is_informative: boolean indicating if the review is informative
|
||||
|
||||
Lecture context: {lectureContext}
|
||||
Review text: {reviewText}
|
||||
""";
|
||||
var promptSetting = await _reviewPrompts.GetAsync();
|
||||
var prompt = ReviewPromptTemplate.Render(promptSetting.Prompt, reviewText, lectureContext);
|
||||
|
||||
var request = new
|
||||
{
|
||||
@@ -49,11 +50,37 @@ public class LlmClient : ILlmClient
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var content = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!;
|
||||
var analysis = JsonSerializer.Deserialize<LlmRawResponse>(content,
|
||||
var analysisJson = NormalizeJsonContent(content);
|
||||
var analysis = JsonSerializer.Deserialize<LlmRawResponse>(analysisJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
return new LlmReviewAnalysis(analysis.QualityScore, analysis.Sentiment, analysis.Tags, analysis.IsInformative);
|
||||
return new LlmReviewAnalysis(
|
||||
Math.Clamp(analysis.QualityScore, 0, 1),
|
||||
analysis.Sentiment ?? "",
|
||||
analysis.Tags ?? [],
|
||||
analysis.IsInformative,
|
||||
content);
|
||||
}
|
||||
|
||||
private record LlmRawResponse(double QualityScore, string Sentiment, string[] Tags, bool IsInformative);
|
||||
private static string NormalizeJsonContent(string content)
|
||||
{
|
||||
var trimmed = content.Trim();
|
||||
if (!trimmed.StartsWith("```", StringComparison.Ordinal))
|
||||
return trimmed;
|
||||
|
||||
var firstNewLine = trimmed.IndexOf('\n');
|
||||
if (firstNewLine < 0)
|
||||
return trimmed;
|
||||
|
||||
var lastFence = trimmed.LastIndexOf("```", StringComparison.Ordinal);
|
||||
return lastFence > firstNewLine
|
||||
? trimmed[(firstNewLine + 1)..lastFence].Trim()
|
||||
: trimmed[(firstNewLine + 1)..].Trim();
|
||||
}
|
||||
|
||||
private record LlmRawResponse(
|
||||
[property: JsonPropertyName("quality_score")] double QualityScore,
|
||||
string? Sentiment,
|
||||
string[]? Tags,
|
||||
[property: JsonPropertyName("is_informative")] bool IsInformative);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Identity.Client;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Exceptions;
|
||||
|
||||
namespace UniVerse.Infrastructure.ExternalServices;
|
||||
|
||||
public class MicrosoftAuthClient : IMicrosoftAuthClient
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public MicrosoftAuthClient(IConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
|
||||
string authorizationCode,
|
||||
string redirectUri,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = _config["AzureAd:TenantId"];
|
||||
var clientId = _config["AzureAd:ClientId"];
|
||||
var clientSecret = _config["AzureAd:ClientSecret"];
|
||||
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId)
|
||||
|| string.IsNullOrWhiteSpace(clientId)
|
||||
|| string.IsNullOrWhiteSpace(clientSecret))
|
||||
throw new UnauthorizedException("Аутентификация Microsoft не настроена (AzureAd:TenantId/ClientId/ClientSecret).");
|
||||
|
||||
var authority = $"{instance.TrimEnd('/')}/{tenantId}";
|
||||
|
||||
var app = ConfidentialClientApplicationBuilder.Create(clientId)
|
||||
.WithClientSecret(clientSecret)
|
||||
.WithAuthority(new Uri(authority))
|
||||
.WithRedirectUri(redirectUri)
|
||||
.Build();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await app.AcquireTokenByAuthorizationCode(["User.Read"], authorizationCode)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
return new MicrosoftTokenResult(result.IdToken);
|
||||
}
|
||||
catch (MsalException ex)
|
||||
{
|
||||
throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UniVerse.Application.DTOs.Sync;
|
||||
@@ -21,18 +24,119 @@ public class ModeusApiClient : IModeusApiClient
|
||||
|
||||
public async Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request)
|
||||
{
|
||||
var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId };
|
||||
var pageSize = request.Size is > 0 ? request.Size.Value : 900;
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["size"] = pageSize,
|
||||
["timeMin"] = request.TimeMin,
|
||||
["timeMax"] = request.TimeMax
|
||||
};
|
||||
|
||||
AddNonEmpty(body, "roomId", request.RoomId);
|
||||
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
|
||||
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
|
||||
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
|
||||
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
|
||||
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
|
||||
AddNonEmpty(body, "profileName", request.ProfileName);
|
||||
AddNonEmpty(body, "curriculumId", request.CurriculumId);
|
||||
AddNonEmpty(body, "typeId", request.TypeId);
|
||||
|
||||
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new());
|
||||
var requestJson = JsonSerializer.Serialize(body);
|
||||
await EnsureSuccessAsync(response, "Modeus events search",
|
||||
BuildEventsRequestSummary(requestJson));
|
||||
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
|
||||
BuildEventsRequestSummary(requestJson))
|
||||
?? new ModeusEventsResponse();
|
||||
}
|
||||
|
||||
public async Task<ModeusRoomsResponse> SearchRoomsAsync()
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", new { });
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new(new());
|
||||
const int pageSize = 100;
|
||||
var allRooms = new List<ModeusRoom>();
|
||||
var page = 0;
|
||||
var totalPages = 1;
|
||||
|
||||
do
|
||||
{
|
||||
var body = new
|
||||
{
|
||||
name = "",
|
||||
sort = "+building.name,+name",
|
||||
size = pageSize,
|
||||
page,
|
||||
deleted = false
|
||||
};
|
||||
|
||||
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", body);
|
||||
await EnsureSuccessAsync(response, "Modeus rooms search",
|
||||
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false");
|
||||
|
||||
var payload = await ReadJsonAsync<ModeusRoomsResponse>(response, "Modeus rooms search",
|
||||
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false")
|
||||
?? new ModeusRoomsResponse();
|
||||
allRooms.AddRange(payload.RoomItems);
|
||||
|
||||
totalPages = payload.Page?.TotalPages ?? page + 1;
|
||||
page++;
|
||||
}
|
||||
while (page < totalPages);
|
||||
|
||||
return new ModeusRoomsResponse { Rooms = allRooms };
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, string requestSummary)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {Truncate(responseBody)}",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
|
||||
|
||||
private static void AddNonEmpty<T>(
|
||||
IDictionary<string, object?> body,
|
||||
string key,
|
||||
IReadOnlyList<T>? values)
|
||||
{
|
||||
if (values is { Count: > 0 })
|
||||
body[key] = values;
|
||||
}
|
||||
|
||||
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, string operation, string requestSummary)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "<empty>";
|
||||
var contentLength = response.Content.Headers.ContentLength?.ToString() ?? "<unknown>";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"{operation} returned HTTP {(int)response.StatusCode} {response.ReasonPhrase} with an empty response body. Request: {requestSummary}. Content-Type: {contentType}. Content-Length: {contentLength}.",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{operation} returned invalid JSON. Request: {requestSummary}. Content-Type: {contentType}. Response body: {Truncate(responseBody)}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value) =>
|
||||
value.Length > 2000 ? string.Concat(value.AsSpan(0, 2000), "...<truncated>") : value;
|
||||
|
||||
public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname)
|
||||
{
|
||||
@@ -40,4 +144,20 @@ public class ModeusApiClient : IModeusApiClient
|
||||
$"/api/schedule/searchemployee?fullname={Uri.EscapeDataString(fullname)}");
|
||||
return response ?? new();
|
||||
}
|
||||
|
||||
public async Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"/api/universe/subid?fullname={Uri.EscapeDataString(fullname)}");
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
|
||||
|
||||
using var response = await _http.SendAsync(request, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
await EnsureSuccessAsync(response, "Universe user sub lookup", $"fullname={fullname}");
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(body) ? null : body.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -9,10 +9,10 @@ using UniVerse.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace UniVerse.Infrastructure.Data.Migrations
|
||||
namespace UniVerse.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260428124938_Initial")]
|
||||
[Migration("20260506134139_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
+1
-1
@@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace UniVerse.Infrastructure.Data.Migrations
|
||||
namespace UniVerse.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
+979
@@ -0,0 +1,979 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace UniVerse.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260511011508_UserRolesJoinTable")]
|
||||
partial class UserRolesJoinTable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_llm_status", "review_llm_status", new[] { "pending", "analyzed", "rejected" });
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_sentiment", "review_sentiment", new[] { "positive", "neutral", "negative" });
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tag_type", "tag_type", new[] { "institute", "faculty", "subject", "organization", "topic", "other" });
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_role", "user_role", new[] { "student", "teacher", "admin" });
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CoinReward")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("coin_reward");
|
||||
|
||||
b.Property<string>("Condition")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("condition");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("icon_url");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("XpReward")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("xp_reward");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("achievements", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("AchievementId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("achievement_id");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<int?>("ReviewId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("review_id");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AchievementId");
|
||||
|
||||
b.HasIndex("ReviewId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("coin_transactions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Course", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_id");
|
||||
|
||||
b.Property<bool>("IsSynced")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_synced");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalId")
|
||||
.IsUnique()
|
||||
.HasFilter("external_id IS NOT NULL");
|
||||
|
||||
b.ToTable("courses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b =>
|
||||
{
|
||||
b.Property<int>("CourseId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("course_id");
|
||||
|
||||
b.Property<int>("TagId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("tag_id");
|
||||
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.HasKey("CourseId", "TagId");
|
||||
|
||||
b.HasIndex("TagId");
|
||||
|
||||
b.HasIndex("CourseId", "TagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("course_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CourseId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("course_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ends_at");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_id");
|
||||
|
||||
b.Property<int>("Format")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("format");
|
||||
|
||||
b.Property<bool>("IsOpen")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_open");
|
||||
|
||||
b.Property<int?>("LocationId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("location_id");
|
||||
|
||||
b.Property<int>("MaxEnrollments")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("max_enrollments");
|
||||
|
||||
b.Property<string>("OnlineUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("online_url");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("starts_at");
|
||||
|
||||
b.Property<int?>("TeacherId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("teacher_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CourseId");
|
||||
|
||||
b.HasIndex("ExternalId")
|
||||
.IsUnique()
|
||||
.HasFilter("external_id IS NOT NULL");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.HasIndex("TeacherId");
|
||||
|
||||
b.ToTable("lectures", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b =>
|
||||
{
|
||||
b.Property<int>("LectureId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("lecture_id");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<bool>("Attended")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("attended");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.HasKey("LectureId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("LectureId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("lecture_enrollments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Location", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("address");
|
||||
|
||||
b.Property<string>("Building")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("building");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Room")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("room");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalId")
|
||||
.IsUnique()
|
||||
.HasFilter("external_id IS NOT NULL");
|
||||
|
||||
b.ToTable("locations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("token");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Review", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<bool?>("IsInformative")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_informative");
|
||||
|
||||
b.Property<int>("LectureId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("lecture_id");
|
||||
|
||||
b.Property<int>("LlmStatus")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("llm_status");
|
||||
|
||||
b.PrimitiveCollection<string[]>("LlmTags")
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("llm_tags");
|
||||
|
||||
b.Property<double?>("QualityScore")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("quality_score");
|
||||
|
||||
b.Property<int>("Rating")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("rating");
|
||||
|
||||
b.Property<int?>("Sentiment")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sentiment");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LlmStatus");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("LectureId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("reviews", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("EnrollmentYear")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("enrollment_year");
|
||||
|
||||
b.Property<string>("Faculty")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("faculty");
|
||||
|
||||
b.Property<string>("GroupName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("group_name");
|
||||
|
||||
b.Property<string>("Specialty")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("specialty");
|
||||
|
||||
b.Property<string>("StudentId")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("student_id");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("student_profiles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int?>("ParentId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("parent_id");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("bio");
|
||||
|
||||
b.Property<string>("Department")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("department");
|
||||
|
||||
b.Property<string>("ModeusId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("modeus_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("teacher_profiles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<int>("Coins")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("coins");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<string>("MicrosoftId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("microsoft_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("xp");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("MicrosoftId")
|
||||
.IsUnique()
|
||||
.HasFilter("microsoft_id IS NOT NULL");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b =>
|
||||
{
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<int>("AchievementId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("achievement_id");
|
||||
|
||||
b.Property<DateTime>("AwardedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("awarded_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.HasKey("UserId", "AchievementId");
|
||||
|
||||
b.HasIndex("AchievementId");
|
||||
|
||||
b.HasIndex("UserId", "AchievementId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("user_achievements", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b =>
|
||||
{
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.HasKey("UserId", "Role");
|
||||
|
||||
b.ToTable("user_roles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement")
|
||||
.WithMany()
|
||||
.HasForeignKey("AchievementId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.Review", "Review")
|
||||
.WithMany("CoinTransactions")
|
||||
.HasForeignKey("ReviewId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithMany("CoinTransactions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Achievement");
|
||||
|
||||
b.Navigation("Review");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.Course", "Course")
|
||||
.WithMany("CourseTags")
|
||||
.HasForeignKey("CourseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.Tag", "Tag")
|
||||
.WithMany("CourseTags")
|
||||
.HasForeignKey("TagId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Course");
|
||||
|
||||
b.Navigation("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.Course", "Course")
|
||||
.WithMany("Lectures")
|
||||
.HasForeignKey("CourseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.Location", "Location")
|
||||
.WithMany("Lectures")
|
||||
.HasForeignKey("LocationId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "Teacher")
|
||||
.WithMany()
|
||||
.HasForeignKey("TeacherId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Course");
|
||||
|
||||
b.Navigation("Location");
|
||||
|
||||
b.Navigation("Teacher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture")
|
||||
.WithMany("Enrollments")
|
||||
.HasForeignKey("LectureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithMany("Enrollments")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Lecture");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Review", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture")
|
||||
.WithMany("Reviews")
|
||||
.HasForeignKey("LectureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithMany("Reviews")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Lecture");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithOne("StudentProfile")
|
||||
.HasForeignKey("UniVerse.Domain.Entities.StudentProfile", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.Tag", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithOne("TeacherProfile")
|
||||
.HasForeignKey("UniVerse.Domain.Entities.TeacherProfile", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement")
|
||||
.WithMany("UserAchievements")
|
||||
.HasForeignKey("AchievementId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithMany("UserAchievements")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Achievement");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b =>
|
||||
{
|
||||
b.HasOne("UniVerse.Domain.Entities.User", "User")
|
||||
.WithMany("Roles")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b =>
|
||||
{
|
||||
b.Navigation("UserAchievements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Course", b =>
|
||||
{
|
||||
b.Navigation("CourseTags");
|
||||
|
||||
b.Navigation("Lectures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b =>
|
||||
{
|
||||
b.Navigation("Enrollments");
|
||||
|
||||
b.Navigation("Reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Location", b =>
|
||||
{
|
||||
b.Navigation("Lectures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Review", b =>
|
||||
{
|
||||
b.Navigation("CoinTransactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("CourseTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("UniVerse.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Navigation("CoinTransactions");
|
||||
|
||||
b.Navigation("Enrollments");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
|
||||
b.Navigation("Reviews");
|
||||
|
||||
b.Navigation("Roles");
|
||||
|
||||
b.Navigation("StudentProfile");
|
||||
|
||||
b.Navigation("TeacherProfile");
|
||||
|
||||
b.Navigation("UserAchievements");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user