diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e37b528 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/backend-ci.yml b/.gitea/workflows/backend-ci.yml new file mode 100644 index 0000000..fb2a95c --- /dev/null +++ b/.gitea/workflows/backend-ci.yml @@ -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 diff --git a/.gitea/workflows/frontend-ci.yml b/.gitea/workflows/frontend-ci.yml new file mode 100644 index 0000000..6d47662 --- /dev/null +++ b/.gitea/workflows/frontend-ci.yml @@ -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 diff --git a/.gitea/workflows/gitea-push-docker.yml b/.gitea/workflows/gitea-push-docker.yml index 65417ff..06e4422 100644 --- a/.gitea/workflows/gitea-push-docker.yml +++ b/.gitea/workflows/gitea-push-docker.yml @@ -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 diff --git a/.gitignore b/.gitignore index 928da2c..4d98306 100644 --- a/.gitignore +++ b/.gitignore @@ -139,7 +139,8 @@ $RECYCLE.BIN/ .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* @@ -160,3 +161,4 @@ Network Trash Folder Temporary Items .apdisk +backend/UniVerse.Api/appsettings.Development.json diff --git a/README.md b/README.md index beed8e9..a99ab92 100644 --- a/README.md +++ b/README.md @@ -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 +``` + diff --git a/backend/.idea/.idea.UniVerse/.idea/.name b/backend/.idea/.idea.UniVerse/.idea/.name new file mode 100644 index 0000000..adb8b9a --- /dev/null +++ b/backend/.idea/.idea.UniVerse/.idea/.name @@ -0,0 +1 @@ +UniVerse \ No newline at end of file diff --git a/backend/.idea/.idea.UniVerse/.idea/indexLayout.xml b/backend/.idea/.idea.UniVerse/.idea/indexLayout.xml index 7b08163..16d8e1d 100644 --- a/backend/.idea/.idea.UniVerse/.idea/indexLayout.xml +++ b/backend/.idea/.idea.UniVerse/.idea/indexLayout.xml @@ -1,7 +1,9 @@ - + + ../frontend + diff --git a/backend/.idea/.idea.UniVerse/.idea/vcs.xml b/backend/.idea/.idea.UniVerse/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/backend/.idea/.idea.UniVerse/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..1aefd4f --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,6 @@ + + + false + true + + diff --git a/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs new file mode 100644 index 0000000..f81fa63 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs @@ -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(() => 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(() => 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(); + microsoftAuth.ExchangeAuthorizationCodeAsync("code", "http://localhost/callback", Arg.Any()) + .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() + .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 + { + ["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(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(1); + + var notifications = Substitute.For(); + notifications.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + microsoftAuth ??= Substitute.For(); + return new AuthService(db, config, microsoftAuth, gamification, notifications, NullLogger.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); + } +} diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs new file mode 100644 index 0000000..05f7254 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -0,0 +1,317 @@ +using System.Net; +using UniVerse.Api.Tests.Helpers; +using Xunit; + +namespace UniVerse.Api.Tests.Authorization; + +/// +/// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API. +/// +/// Каждый тестовый случай представляет собой кортеж: +/// (description, method, url, requiredRole, forbiddenRoles[]) +/// +/// Три типа сценариев для каждой конечной точки: +/// A) Анонимный → 401 Unauthorized +/// B) Неправильная роль → 403 Forbidden +/// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка) +/// +public class EndpointAuthorizationTests : IClassFixture +{ + private readonly HttpClient _client; + + public EndpointAuthorizationTests(ApiWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Тестовые данные + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Конечные точки, требующие аутентификации (не анонимные). + /// Формат: (description, method, url, correctRole, forbiddenRoles[]) + /// + /// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли. + /// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные. + /// + public static IEnumerable 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}}"}"""); + } + + /// + /// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401. + /// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401) + /// + public static IEnumerable 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; + } + + /// Вспомогательный метод для компактного создания массивов объектов [MemberData]. + private static object[] E( + string description, + string method, + string url, + string correctRole, + string[]? forbidden = null, + string? body = null) + => [description, method, url, correctRole, forbidden ?? [], body]; +} diff --git a/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs b/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs new file mode 100644 index 0000000..d7f9974 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs @@ -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() + .UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private static GamificationService CreateService(AppDbContext db) + { + var notifications = Substitute.For(); + notifications.CreateUserNotificationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt(1), callInfo.ArgAt(2), callInfo.ArgAt(3), false, DateTime.UtcNow)); + notifications.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + return new GamificationService(db, notifications, NullLogger.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) + }; +} diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs new file mode 100644 index 0000000..0367cfc --- /dev/null +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -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; + +/// +/// WebApplicationFactory для интеграционных тестов. +/// Заменяет Npgsql DbContext на InMemory, создает заглушки для всех интерфейсов внешних сервисов +/// и отключает фоновую службу LLM, чтобы тестам не требовалась реальная инфраструктура. +/// +public class ApiWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Используем Development, чтобы были включены Swagger и конечная точка DevLogin + builder.UseEnvironment("Development"); + + builder.ConfigureAppConfiguration((_, config) => + { + // Внедряем настройки тестового JWT — должны совпадать с константами TestJwtFactory + var testSettings = new Dictionary + { + ["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>(); + services.RemoveAll(); + + // Удаляем все регистрации, связанные с DbContext, которые добавил хост + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) services.Remove(descriptor); + + // Находим и удаляем все дескрипторы настроек DbContext + var dbContextDescriptors = services + .Where(d => d.ServiceType == typeof(DbContextOptions) + || d.ImplementationType == typeof(AppDbContext)) + .ToList(); + foreach (var d in dbContextDescriptors) services.Remove(d); + + services.AddDbContext(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(services, CreateAuthServiceStub()); + ReplaceWithSubstitute(services, CreateUserServiceStub()); + ReplaceWithSubstitute(services, CreateLectureServiceStub()); + ReplaceWithSubstitute(services, CreateReviewServiceStub()); + ReplaceWithSubstitute(services, CreateReviewPromptServiceStub()); + ReplaceWithSubstitute(services, CreateCourseServiceStub()); + ReplaceWithSubstitute(services, CreateTagServiceStub()); + ReplaceWithSubstitute(services, CreateLocationServiceStub()); + ReplaceWithSubstitute(services, CreateAchievementServiceStub()); + ReplaceWithSubstitute(services, CreateGamificationServiceStub()); + ReplaceWithSubstitute(services, CreateSyncServiceStub()); + ReplaceWithSubstitute(services, Substitute.For()); + ReplaceWithSubstitute(services, Substitute.For()); + ReplaceWithSubstitute(services, Substitute.For()); + ReplaceWithSubstitute(services, CreateNotificationServiceStub()); + }); + } + + private static void ReplaceWithSubstitute(IServiceCollection services, TService instance) + where TService : class + { + services.RemoveAll(); + services.AddScoped(_ => instance); + } + + // ── Фабрики заглушек ──────────────────────────────────────────────────────────── + + private static IAuthService CreateAuthServiceStub() + { + var stub = Substitute.For(); + 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(), Arg.Any(), Arg.Any()) + .Returns(authResult); + stub.DevLoginAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(authResult); + stub.RefreshTokenAsync(Arg.Any()).Returns(authResult); + stub.GetCurrentUserAsync(Arg.Any()) + .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(); + stub.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + stub.ScheduleAsync(Arg.Any(), Arg.Any()) + .Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5))); + stub.GetUserNotificationsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(PagedResult.Create([], 0, 1, 20)); + stub.MarkAllReadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + stub.CreateUserNotificationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new UserNotificationDto(1, "achievement", "Title", "Body", false, DateTime.UtcNow)); + return stub; + } + + private static IUserService CreateUserServiceStub() + { + var stub = Substitute.For(); + var userDto = new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow); + var pagedUsers = PagedResult.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.Create([lectureDto], 1, 1, 20); + + stub.GetByIdAsync(Arg.Any()).Returns(userDto); + stub.UpdateProfileAsync(Arg.Any(), Arg.Any()).Returns(userDto); + stub.GetStatsAsync(Arg.Any()).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(), Arg.Any()).Returns(pagedLectures); + stub.GetAllAsync(Arg.Any()).Returns(pagedUsers); + stub.SetRolesAsync(Arg.Any(), Arg.Any>()).Returns(Task.CompletedTask); + stub.SetActiveAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + return stub; + } + + private static ILectureService CreateLectureServiceStub() + { + var stub = Substitute.For(); + 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.Create([lectureDto], 1, 1, 20); + var pagedEnrollments = PagedResult.Create([], 0, 1, 20); + + stub.GetAllAsync(Arg.Any(), Arg.Any()).Returns(pagedLectures); + stub.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(detailDto); + stub.CreateAsync(Arg.Any()).Returns(lectureDto); + stub.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(lectureDto); + stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask); + stub.EnrollAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + stub.UnenrollAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + stub.MarkAttendanceAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + stub.GetEnrollmentsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(pagedEnrollments); + return stub; + } + + private static IReviewService CreateReviewServiceStub() + { + var stub = Substitute.For(); + var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User", + ReviewRating.Like, "Great!", ReviewLlmStatus.Pending, + null, null, null, null, null, DateTime.UtcNow); + var pagedReviews = PagedResult.Create([reviewDto], 1, 1, 20); + + stub.CreateAsync(Arg.Any(), Arg.Any()).Returns(reviewDto); + stub.GetByIdAsync(Arg.Any()).Returns(reviewDto); + stub.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(reviewDto); + stub.DeleteAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + stub.GetByLectureAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(pagedReviews); + stub.GetByUserAsync(Arg.Any(), Arg.Any()).Returns(pagedReviews); + stub.GetAllAsync(Arg.Any()).Returns(pagedReviews); + stub.ReanalyzeAsync(Arg.Any()).Returns(Task.CompletedTask); + return stub; + } + + private static IReviewPromptService CreateReviewPromptServiceStub() + { + var stub = Substitute.For(); + var promptDto = new ReviewPromptDto( + "Analyze {lectureContext}. Review: {reviewText}", + DateTime.UtcNow); + + stub.GetAsync().Returns(promptDto); + stub.UpdateAsync(Arg.Any()).Returns(callInfo => + new ReviewPromptDto(callInfo.Arg().Prompt, DateTime.UtcNow)); + return stub; + } + + private static ICourseService CreateCourseServiceStub() + { + var stub = Substitute.For(); + var courseDto = new CourseDto(1, "Course", null, false, [], DateTime.UtcNow); + var paged = PagedResult.Create([courseDto], 1, 1, 20); + + stub.GetAllAsync(Arg.Any()).Returns(paged); + stub.GetByIdAsync(Arg.Any()).Returns(courseDto); + stub.CreateAsync(Arg.Any()).Returns(courseDto); + stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(courseDto); + stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask); + stub.AddTagAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + stub.RemoveTagAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + return stub; + } + + private static ITagService CreateTagServiceStub() + { + var stub = Substitute.For(); + var tagDto = new TagDto(1, "Tag", TagType.Topic, null, DateTime.UtcNow); + + stub.GetAllAsync(Arg.Any(), Arg.Any()).Returns([tagDto]); + stub.GetByIdAsync(Arg.Any()).Returns(tagDto); + stub.CreateAsync(Arg.Any()).Returns(tagDto); + stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(tagDto); + stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask); + stub.GetTreeAsync().Returns(new List()); + return stub; + } + + private static ILocationService CreateLocationServiceStub() + { + var stub = Substitute.For(); + var locationDto = new LocationDto(1, "Room 101", null, null, null, DateTime.UtcNow); + + stub.GetAllAsync().Returns([locationDto]); + stub.GetByIdAsync(Arg.Any()).Returns(locationDto); + stub.CreateAsync(Arg.Any()).Returns(locationDto); + stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(locationDto); + stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask); + return stub; + } + + private static IAchievementService CreateAchievementServiceStub() + { + var stub = Substitute.For(); + var achievementDto = new AchievementDto(1, "First Review", null, null, 10, 5, null, DateTime.UtcNow); + + stub.GetAllAsync().Returns([achievementDto]); + stub.GetByIdAsync(Arg.Any()).Returns(achievementDto); + stub.CreateAsync(Arg.Any()).Returns(achievementDto); + stub.UpdateAsync(Arg.Any(), Arg.Any()).Returns(achievementDto); + stub.DeleteAsync(Arg.Any()).Returns(Task.CompletedTask); + return stub; + } + + private static IGamificationService CreateGamificationServiceStub() + { + var stub = Substitute.For(); + var paged = PagedResult.Create([], 0, 1, 20); + + stub.GetUserAchievementsAsync(Arg.Any()).Returns(new List()); + stub.GetTransactionsAsync(Arg.Any(), Arg.Any()).Returns(paged); + stub.AwardCoinsAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + stub.CheckAndAwardAchievementsAsync(Arg.Any()).Returns(Task.CompletedTask); + stub.CalculateLevelAsync(Arg.Any()).Returns(Task.FromResult(1)); + stub.GetLevelProgressAsync(Arg.Any()).Returns(Task.FromResult(new LevelProgressDto(0, 100))); + return stub; + } + + private static IScheduleSyncService CreateSyncServiceStub() + { + var stub = Substitute.For(); + var syncResult = new SyncResultDto(0, 0, 0, null); + var syncStatus = new SyncStatusDto(null, "idle", null); + + stub.SyncScheduleAsync(Arg.Any()).Returns(syncResult); + stub.SyncRoomsAsync().Returns(syncResult); + stub.SearchEmployeesAsync(Arg.Any()).Returns(new List()); + stub.GetLastSyncStatusAsync().Returns(syncStatus); + return stub; + } +} diff --git a/backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs b/backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs new file mode 100644 index 0000000..e6b6864 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs @@ -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; + +/// +/// Генерирует подписанные JWT токены для использования в интеграционных тестах. +/// Использует те же секрет/издателя/аудиторию (secret/issuer/audience), которые внедряет ApiWebApplicationFactory. +/// +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"; + + /// Создает валидную строку токена JWT (bearer) для заданной роли и идентификатора пользователя. + 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); + } + + /// Создает значение заголовка Authorization: "Bearer <token>". + public static string BearerHeader(string role, int userId = 1) + => $"Bearer {Generate(role, userId)}"; +} diff --git a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs new file mode 100644 index 0000000..9ed1966 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs @@ -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(), Substitute.For()); + 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(); + var service = new LectureService(db, Substitute.For(), 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(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 3 часа")), + Arg.Is(d => d == new DateTimeOffset(startsAt.AddHours(-3))), + "lecture-1-user-1-starts-in-3-hours", + Arg.Any()); + await scheduler.Received(1).ScheduleAsync( + Arg.Is(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 1 час")), + Arg.Is(d => d == new DateTimeOffset(startsAt.AddHours(-1))), + "lecture-1-user-1-starts-in-1-hour", + Arg.Any()); + await scheduler.Received(1).ScheduleAsync( + Arg.Is(m => m.Recipient == "student@test.local" && m.Subject.Contains("Оцените")), + Arg.Is(d => d == new DateTimeOffset(startsAt.AddHours(2))), + "lecture-1-user-1-ended", + Arg.Any()); + } + + [Fact] + public async Task EnrollAsync_SkipsPastLectureReminders() + { + await using var db = CreateDbContext(); + var scheduler = Substitute.For(); + var service = new LectureService(db, Substitute.For(), 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(), + Arg.Any(), + "lecture-1-user-1-starts-in-3-hours", + Arg.Any()); + await scheduler.Received(2).ScheduleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [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(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(level); + var service = new LectureService(db, gamification, Substitute.For()); + 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(() => service.EnrollAsync(100, 1)); + } + + [Fact] + public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit() + { + await using var db = CreateDbContext(); + var gamification = Substitute.For(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(1); + var service = new LectureService(db, gamification, Substitute.For()); + 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(() => service.EnrollAsync(100, 1)); + } + + [Fact] + public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit() + { + await using var db = CreateDbContext(); + var gamification = Substitute.For(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(1); + var service = new LectureService(db, gamification, Substitute.For()); + 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(); + var service = new LectureService(db, Substitute.For(), 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()); + await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-1-hour", Arg.Any()); + await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any()); + } + + [Fact] + public async Task UpdateAsync_TeacherCannotUpdateAnotherTeachersLecture() + { + await using var db = CreateDbContext(); + var service = new LectureService(db, Substitute.For(), Substitute.For()); + 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(() => service.UpdateAsync(1, request, currentUserId: 1)); + } + + [Fact] + public async Task GetEnrollmentsAsync_AdminCanReadAnyLecture() + { + await using var db = CreateDbContext(); + var service = new LectureService(db, Substitute.For(), Substitute.For()); + 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() + .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 + }; +} diff --git a/backend/UniVerse.Api.Tests/Reviews/LlmAnalysisServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/LlmAnalysisServiceTests.cs new file mode 100644 index 0000000..f93471c --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/LlmAnalysisServiceTests.cs @@ -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(); + llm.AnalyzeReviewAsync(Arg.Any(), Arg.Any()) + .Returns(new LlmReviewAnalysis( + 0.76, + "Положительный", + ["lecture structure", "practical examples"], + true, + "{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}")); + var gamification = Substitute.For(); + gamification.AwardCoinsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + gamification.CheckAndAwardAchievementsAsync(Arg.Any()).Returns(Task.CompletedTask); + var service = new LlmAnalysisService(db, llm, gamification, NullLogger.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() + .UseInMemoryDatabase($"LlmAnalysisServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewAnalysisWorkerTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewAnalysisWorkerTests.cs new file mode 100644 index 0000000..9ddfe13 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewAnalysisWorkerTests.cs @@ -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.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(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; + } + } + } +} diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs new file mode 100644 index 0000000..0e1b517 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs @@ -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(() => + 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 + { + ["Llm:Model"] = "test-model", + ["Llm:ApiKey"] = "test-key" + }) + .Build(); + var promptService = Substitute.For(); + promptService.GetAsync().Returns(new ReviewPromptDto( + "Custom prompt. Context: {lectureContext}. Text: {reviewText}", + DateTime.UtcNow)); + var client = new LlmClient(http, config, promptService, NullLogger.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 + { + ["Llm:Model"] = "test-model", + ["Llm:ApiKey"] = "test-key" + }) + .Build(); + var promptService = Substitute.For(); + promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null)); + var client = new LlmClient(http, config, promptService, NullLogger.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() + .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 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") + }; + } + } +} diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs new file mode 100644 index 0000000..ac58468 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs @@ -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(); + 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()); + } + + [Fact] + public async Task UpdateAsync_ResetsAnalysisAndEnqueuesReview() + { + await using var db = CreateDbContext(); + var queue = Substitute.For(); + 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()); + } + + [Fact] + public async Task ReanalyzeAsync_ResetsAnalysisAndEnqueuesReview() + { + await using var db = CreateDbContext(); + var queue = Substitute.For(); + 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()); + } + + [Fact] + public async Task GetByLectureAsync_TeacherCannotReadAnotherTeachersReviews() + { + await using var db = CreateDbContext(); + var service = CreateService(db, Substitute.For()); + await SeedAnalyzedReviewAsync(db, teacherId: 2); + + await Assert.ThrowsAsync(() => + service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1)); + } + + [Fact] + public async Task GetByLectureAsync_AdminCanReadAnyLectureReviews() + { + await using var db = CreateDbContext(); + var service = CreateService(db, Substitute.For()); + 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(); + gamification.CheckAndAwardAchievementsAsync(Arg.Any()).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() + .UseInMemoryDatabase($"ReviewServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Api.Tests/Swagger/SwaggerDocumentTests.cs b/backend/UniVerse.Api.Tests/Swagger/SwaggerDocumentTests.cs new file mode 100644 index 0000000..aa7cd06 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Swagger/SwaggerDocumentTests.cs @@ -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 +{ + 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()); + } +} diff --git a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs new file mode 100644 index 0000000..0f7180b --- /dev/null +++ b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs @@ -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(); + modeus.SearchEventsAsync(Arg.Any()) + .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.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(); + modeus.SearchEventsAsync(Arg.Any()) + .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.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.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.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.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.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.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(); + modeus.SearchEventsAsync(Arg.Any()).Returns(BuildEventsResponse()); + var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); + + var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); + + Assert.Null(result.Error); + await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any(), Arg.Any()); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .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 SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events); + + public Task SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse()); + + public Task> SearchEmployeeAsync(string fullname) => Task.FromResult(new List()); + + public Task GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default) + { + if (throwOnSubLookup) + throw new HttpRequestException("lookup failed"); + + return Task.FromResult(subId); + } + } +} diff --git a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj new file mode 100644 index 0000000..c3a346e --- /dev/null +++ b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs new file mode 100644 index 0000000..7e13394 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs @@ -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() + .UseInMemoryDatabase($"UserServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private static UserService CreateService(AppDbContext db) + { + var notifications = Substitute.For(); + notifications.CreateUserNotificationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt(1), callInfo.ArgAt(2), callInfo.ArgAt(3), false, DateTime.UtcNow)); + notifications.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var gamification = new GamificationService(db, notifications, NullLogger.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 + }; +} diff --git a/backend/UniVerse.Api/BackgroundServices/AchievementCatalogHostedService.cs b/backend/UniVerse.Api/BackgroundServices/AchievementCatalogHostedService.cs new file mode 100644 index 0000000..15eb6f8 --- /dev/null +++ b/backend/UniVerse.Api/BackgroundServices/AchievementCatalogHostedService.cs @@ -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; +} diff --git a/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs b/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs deleted file mode 100644 index ef5b733..0000000 --- a/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs +++ /dev/null @@ -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 _logger; - - public LlmProcessingBackgroundService(IServiceProvider services, ILogger 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(); - await llmService.ProcessPendingReviewsAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in LLM processing background service"); - } - await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); - } - } -} diff --git a/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisQueue.cs b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisQueue.cs new file mode 100644 index 0000000..8301f35 --- /dev/null +++ b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisQueue.cs @@ -0,0 +1,21 @@ +using System.Threading.Channels; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Api.BackgroundServices; + +public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = false, + SingleWriter = false + }); + + public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default) + { + await _channel.Writer.WriteAsync(reviewId, cancellationToken); + } + + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); +} diff --git a/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisWorker.cs b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisWorker.cs new file mode 100644 index 0000000..f9851bc --- /dev/null +++ b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisWorker.cs @@ -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 _logger; + + public ReviewAnalysisWorker( + IServiceProvider services, + ReviewAnalysisQueue queue, + IOptions options, + ILogger 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(); + + 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(); + 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); + } + } + } +} diff --git a/backend/UniVerse.Api/Controllers/AchievementsController.cs b/backend/UniVerse.Api/Controllers/AchievementsController.cs index 62cda9c..c4ea491 100644 --- a/backend/UniVerse.Api/Controllers/AchievementsController.cs +++ b/backend/UniVerse.Api/Controllers/AchievementsController.cs @@ -5,31 +5,87 @@ using UniVerse.Application.Interfaces; namespace UniVerse.Api.Controllers; +/// Управление определениями достижений системы геймификации. [ApiController] [Route("api/v1/achievements")] [Authorize] +[Produces("application/json")] public class AchievementsController : ControllerBase { private readonly IAchievementService _achievements; + public AchievementsController(IAchievementService achievements) => _achievements = achievements; + /// Получить список всех достижений. + /// Возвращает определения достижений (без информации о получении конкретным пользователем). + /// Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements. + /// Список достижений. + /// Требуется аутентификация. [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task GetAll() => Ok(await _achievements.GetAllAsync()); + /// Получить достижение по ID. + /// ID достижения. + /// Данные достижения. + /// Требуется аутентификация. + /// Достижение не найдено. [HttpGet("{id:int}")] + [ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get(int id) => Ok(await _achievements.GetByIdAsync(id)); + /// Создать новое достижение. + /// Только Admin. Достижения автоматически присваиваются студентам при выполнении условий. + /// Название, описание, иконка, награда в XP/монетах и условие получения. + /// Достижение создано. + /// Требуется аутентификация. + /// Требуется роль Admin. [Authorize(Roles = "Admin")] [HttpPost] + [ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Create([FromBody] CreateAchievementRequest req) => CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req)); + /// Обновить достижение по ID. + /// Только Admin. + /// ID достижения. + /// Обновляемые поля достижения. + /// Обновлённые данные достижения. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Достижение не найдено. [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] + [ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Update(int id, [FromBody] UpdateAchievementRequest req) => Ok(await _achievements.UpdateAsync(id, req)); + /// Удалить достижение по ID. + /// + /// Только Admin. Удаление не отзывает достижение у уже получивших его пользователей. + /// + /// ID достижения. + /// Достижение удалено. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Достижение не найдено. [Authorize(Roles = "Admin")] [HttpDelete("{id:int}")] - public async Task Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); } + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + await _achievements.DeleteAsync(id); + return NoContent(); + } } diff --git a/backend/UniVerse.Api/Controllers/AuthController.cs b/backend/UniVerse.Api/Controllers/AuthController.cs index d0cf3cb..e59b4d5 100644 --- a/backend/UniVerse.Api/Controllers/AuthController.cs +++ b/backend/UniVerse.Api/Controllers/AuthController.cs @@ -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; +/// Аутентификация и управление сессией пользователя. [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; + } + + /// Вход через Microsoft Entra ID (SPA/PKCE flow). + /// + /// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда + /// полученный authorization code. В ответ возвращается пара JWT-токенов; + /// refresh token устанавливается в HttpOnly cookie. + /// + /// Authorization code и redirect URI из Microsoft OAuth2. + /// Успешный вход — возвращает access token и данные пользователя. + /// Неверный или просроченный authorization code. [HttpPost("login/microsoft")] + [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> 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); } + /// Инициация server-driven входа через Microsoft (редирект-flow). + /// + /// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state + /// и редиректит пользователя на `login.microsoftonline.com`. + /// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`. + /// + /// URL для редиректа после успешного входа (опционально). + /// Редирект на Microsoft authorize endpoint. + /// Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют). + [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 + { + ["client_id"] = clientId, + ["response_type"] = "code", + ["redirect_uri"] = redirectUri, + ["response_mode"] = "query", + ["scope"] = scope, + ["state"] = state + }); + + return Redirect(authorizeUrl); + } + + /// OAuth2 callback — обмен code на токены (server-driven flow). + /// + /// Microsoft редиректит браузер сюда после успешного входа. + /// Backend валидирует CSRF state, обменивает code на токены, + /// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте. + /// + /// Authorization code от Microsoft. + /// CSRF state для верификации. + /// Код ошибки от Microsoft (если вход не удался). + /// Описание ошибки от Microsoft. + /// Успешный вход — редирект на returnUrl с токеном в URL-фрагменте. + /// Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования). + /// Отсутствует authorization code. + /// Ошибка от Microsoft или невалидный CSRF state. + [HttpGet("callback/microsoft")] + [AllowAnonymous] + [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task 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); + } + + /// Dev-only вход без OAuth (только в Development-окружении). + /// + /// Создаёт или находит пользователя по email без реального OAuth flow. + /// Возвращает 404 в Production и Staging. + /// + /// Email, отображаемое имя и роль тестового пользователя. + /// Успешный вход. + /// Endpoint недоступен вне Development. [HttpPost("login/dev")] + [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> DevLogin([FromBody] DevLoginRequest request) { if (!HttpContext.RequestServices.GetRequiredService().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); } + /// Обновление access token по refresh token из HttpOnly cookie. + /// + /// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе). + /// Возвращает новую пару токенов и обновляет cookie. + /// + /// Новая пара токенов. + /// Refresh token отсутствует, просрочен или отозван. + /// Аккаунт деактивирован или refresh token недействителен. [HttpPost("refresh")] + [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Refresh() { var refreshToken = Request.Cookies["refreshToken"]; @@ -41,8 +212,17 @@ public class AuthController : ControllerBase return Ok(result.Response); } + /// Выход из системы — отзыв refresh token. + /// + /// Инвалидирует текущий refresh token в БД и удаляет cookie. + /// После этого вызова access token остаётся валидным до истечения его TTL (30 минут). + /// + /// Выход выполнен успешно. + /// Требуется аутентификация. [Authorize] [HttpPost("logout")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task Logout() { var refreshToken = Request.Cookies["refreshToken"]; @@ -52,8 +232,16 @@ public class AuthController : ControllerBase return NoContent(); } + /// Получение профиля текущего авторизованного пользователя. + /// Данные текущего пользователя. + /// Требуется аутентификация. + /// Пользователь не найден в БД (рассинхронизация токена). [Authorize] [HttpGet("me")] + [ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.CurrentUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Me() { var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) @@ -62,12 +250,55 @@ 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 { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, - Expires = DateTime.UtcNow.AddDays(30) + Expires = DateTime.UtcNow.AddDays(30) }); } + + private string BuildAbsoluteUrl(string path) + { + if (!path.StartsWith('/')) path = "/" + path; + return $"{Request.Scheme}://{Request.Host}{path}"; + } + + private bool IsAllowedReturnUrl(string returnUrl) + { + if (Uri.TryCreate(returnUrl, UriKind.Relative, out _)) + return true; + + if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute)) + return false; + + var allowedOrigins = _config.GetSection("Cors:Origins").Get() ?? Array.Empty(); + 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; + } } diff --git a/backend/UniVerse.Api/Controllers/CoursesController.cs b/backend/UniVerse.Api/Controllers/CoursesController.cs index e70369d..904955c 100644 --- a/backend/UniVerse.Api/Controllers/CoursesController.cs +++ b/backend/UniVerse.Api/Controllers/CoursesController.cs @@ -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; +/// Управление курсами (дисциплинами) и их тегами. [ApiController] [Route("api/v1/courses")] [Authorize] +[Produces("application/json")] public class CoursesController : ControllerBase { private readonly ICourseService _courses; + public CoursesController(ICourseService courses) => _courses = courses; + /// Получить список курсов с фильтрацией и пагинацией. + /// Фильтры: tagId, search, isSynced; параметры пагинации. + /// Список курсов (пагинированный). + /// Требуется аутентификация. [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task GetAll([FromQuery] CourseFilterRequest filter) => Ok(await _courses.GetAllAsync(filter)); + /// Получить курс по ID (включая теги). + /// ID курса. + /// Данные курса с тегами. + /// Требуется аутентификация. + /// Курс не найден. [HttpGet("{id:int}")] + [ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get(int id) => Ok(await _courses.GetByIdAsync(id)); + /// Создать новый курс. + /// Только Admin. + /// Название и описание курса. + /// Курс создан. + /// Требуется аутентификация. + /// Требуется роль Admin. [Authorize(Roles = "Admin")] [HttpPost] + [ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Create([FromBody] CreateCourseRequest req) => CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req)); + /// Обновить курс по ID. + /// Только Admin. + /// ID курса. + /// Новое название и/или описание. + /// Обновлённые данные курса. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Курс не найден. [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] + [ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Update(int id, [FromBody] UpdateCourseRequest req) => Ok(await _courses.UpdateAsync(id, req)); + /// Удалить курс по ID. + /// Только Admin. Удаление курса каскадно удаляет связанные лекции. + /// ID курса. + /// Курс удалён. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Курс не найден. [Authorize(Roles = "Admin")] [HttpDelete("{id:int}")] - public async Task Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); } + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + await _courses.DeleteAsync(id); + return NoContent(); + } + /// Привязать тег к курсу. + /// Только Admin. Тег должен существовать в системе. + /// ID курса. + /// ID тега. + /// Тег привязан. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Курс или тег не найден. + /// Тег уже привязан к курсу. [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 AddTag(int id, [FromBody] int tagId) - { await _courses.AddTagAsync(id, tagId); return NoContent(); } + { + await _courses.AddTagAsync(id, tagId); + return NoContent(); + } + /// Отвязать тег от курса. + /// Только Admin. + /// ID курса. + /// ID тега. + /// Тег отвязан. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Курс или тег не найден, либо связь не существует. [Authorize(Roles = "Admin")] [HttpDelete("{id:int}/tags/{tagId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task RemoveTag(int id, int tagId) - { await _courses.RemoveTagAsync(id, tagId); return NoContent(); } + { + await _courses.RemoveTagAsync(id, tagId); + return NoContent(); + } } diff --git a/backend/UniVerse.Api/Controllers/LecturesController.cs b/backend/UniVerse.Api/Controllers/LecturesController.cs index cd201ff..a500870 100644 --- a/backend/UniVerse.Api/Controllers/LecturesController.cs +++ b/backend/UniVerse.Api/Controllers/LecturesController.cs @@ -7,59 +7,203 @@ using System.Security.Claims; namespace UniVerse.Api.Controllers; +/// Каталог лекций — просмотр, управление, запись и отзывы. [ApiController] [Route("api/v1/lectures")] [Authorize] +[Produces("application/json")] public class LecturesController : ControllerBase { private readonly ILectureService _lectures; - private readonly IReviewService _reviews; + 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"); + + /// Получить каталог лекций с фильтрацией и пагинацией. + /// + /// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline), + /// isOpen, tagId, search; параметры пагинации. + /// + /// Включает флаг `isEnrolled` — записан ли текущий пользователь на лекцию. + /// Список лекций (пагинированный). + /// Требуется аутентификация. [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task GetAll([FromQuery] LectureFilterRequest filter) => - Ok(await _lectures.GetAllAsync(filter)); + Ok(await _lectures.GetAllAsync(filter, CurrentUserId)); + /// Получить детальную карточку лекции по ID. + /// + /// Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию. + /// + /// ID лекции. + /// Детальные данные лекции. + /// Требуется аутентификация. + /// Лекция не найдена. [HttpGet("{id:int}")] + [ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Get(int id) => Ok(await _lectures.GetByIdAsync(id, CurrentUserId)); + /// Создать новую лекцию. + /// Только Admin. Курс задаётся при создании и не может быть изменён. + /// Данные лекции: курс, преподаватель, локация, время, формат, вместимость. + /// Лекция создана. + /// Требуется аутентификация. + /// Требуется роль Admin. [Authorize(Roles = "Admin")] [HttpPost] + [ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Create([FromBody] CreateLectureRequest req) => CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req)); + /// Обновить лекцию по ID. + /// Admin или Teacher. CourseId изменить нельзя. + /// ID лекции. + /// Обновляемые поля: преподаватель, локация, время, формат, описание. + /// Обновлённые данные лекции. + /// Требуется аутентификация. + /// Требуется роль Admin или Teacher. + /// Лекция не найдена. [Authorize(Roles = "Admin,Teacher")] [HttpPut("{id:int}")] + [ProducesResponseType(typeof(LectureDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Update(int id, [FromBody] UpdateLectureRequest req) => - Ok(await _lectures.UpdateAsync(id, req)); + Ok(await _lectures.UpdateAsync(id, req, CurrentUserId, CurrentUserIsAdmin)); + /// Удалить лекцию по ID. + /// Только Admin. Каскадно удаляет записи и отзывы. + /// ID лекции. + /// Лекция удалена. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Лекция не найдена. [Authorize(Roles = "Admin")] [HttpDelete("{id:int}")] - public async Task Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); } + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + await _lectures.DeleteAsync(id); + return NoContent(); + } + /// Записаться на лекцию. + /// + /// Только Student. Проверяет наличие свободных мест и отсутствие повторной записи. + /// После посещения начисляются монеты через gamification. + /// + /// ID лекции. + /// Запись выполнена. + /// Требуется аутентификация. + /// Требуется роль Student. + /// Лекция не найдена. + /// Студент уже записан или мест нет. [Authorize(Roles = "Student")] [HttpPost("{id:int}/enroll")] - public async Task 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 Enroll(int id) + { + await _lectures.EnrollAsync(id, CurrentUserId); + return NoContent(); + } + /// Отменить запись на лекцию. + /// Только Student. Отменить можно только свою запись. + /// ID лекции. + /// Запись отменена. + /// Требуется аутентификация. + /// Требуется роль Student. + /// Лекция или запись не найдена. [Authorize(Roles = "Student")] [HttpDelete("{id:int}/enroll")] - public async Task 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 Unenroll(int id) + { + await _lectures.UnenrollAsync(id, CurrentUserId); + return NoContent(); + } + /// Отметить посещение студента на лекции. + /// + /// Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение + /// через gamification service. + /// + /// ID лекции. + /// ID студента. + /// true — посетил, false — не посетил. + /// Посещение отмечено. + /// Требуется аутентификация. + /// Требуется роль Admin или Teacher. + /// Лекция или запись студента не найдена. [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 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(); + } + /// Получить список записавшихся студентов на лекцию. + /// Только Admin или Teacher. Включает флаг посещения (`attended`). + /// ID лекции. + /// Параметры пагинации. + /// Список записей (пагинированный). + /// Требуется аутентификация. + /// Требуется роль Admin или Teacher. + /// Лекция не найдена. [Authorize(Roles = "Admin,Teacher")] [HttpGet("{id:int}/enrollments")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Enrollments(int id, [FromQuery] PaginationRequest pagination) => - Ok(await _lectures.GetEnrollmentsAsync(id, pagination)); + Ok(await _lectures.GetEnrollmentsAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin)); + /// Получить отзывы к лекции. + /// Только Admin или Teacher. + /// ID лекции. + /// Параметры пагинации. + /// Список отзывов (пагинированный). + /// Требуется аутентификация. + /// Требуется роль Admin или Teacher. + /// Лекция не найдена. + [Authorize(Roles = "Admin,Teacher")] [HttpGet("{id:int}/reviews")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Reviews(int id, [FromQuery] PaginationRequest pagination) => - Ok(await _reviews.GetByLectureAsync(id, pagination)); - + Ok(await _reviews.GetByLectureAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin)); } diff --git a/backend/UniVerse.Api/Controllers/LocationsController.cs b/backend/UniVerse.Api/Controllers/LocationsController.cs index 7b1e616..a8c3e13 100644 --- a/backend/UniVerse.Api/Controllers/LocationsController.cs +++ b/backend/UniVerse.Api/Controllers/LocationsController.cs @@ -5,31 +5,85 @@ using UniVerse.Application.Interfaces; namespace UniVerse.Api.Controllers; +/// Управление локациями проведения лекций (аудитории, онлайн-площадки). [ApiController] [Route("api/v1/locations")] [Authorize] +[Produces("application/json")] public class LocationsController : ControllerBase { private readonly ILocationService _locations; + public LocationsController(ILocationService locations) => _locations = locations; + /// Получить список всех локаций. + /// Список локаций. + /// Требуется аутентификация. [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task GetAll() => Ok(await _locations.GetAllAsync()); + /// Получить локацию по ID. + /// ID локации. + /// Данные локации. + /// Требуется аутентификация. + /// Локация не найдена. [HttpGet("{id:int}")] + [ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get(int id) => Ok(await _locations.GetByIdAsync(id)); + /// Создать новую локацию. + /// Только Admin. Локации также создаются автоматически при синхронизации с Modeus. + /// Название, корпус, аудитория и/или адрес. + /// Локация создана. + /// Требуется аутентификация. + /// Требуется роль Admin. [Authorize(Roles = "Admin")] [HttpPost] + [ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Create([FromBody] CreateLocationRequest req) => CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req)); + /// Обновить локацию по ID. + /// Только Admin. + /// ID локации. + /// Обновляемые поля: название, корпус, аудитория, адрес. + /// Обновлённые данные локации. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Локация не найдена. [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] + [ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Update(int id, [FromBody] UpdateLocationRequest req) => Ok(await _locations.UpdateAsync(id, req)); + /// Удалить локацию по ID. + /// + /// Только Admin. При удалении локации у связанных лекций поле `locationId` становится null. + /// + /// ID локации. + /// Локация удалена. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Локация не найдена. [Authorize(Roles = "Admin")] [HttpDelete("{id:int}")] - public async Task Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); } + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + await _locations.DeleteAsync(id); + return NoContent(); + } } diff --git a/backend/UniVerse.Api/Controllers/NotificationsController.cs b/backend/UniVerse.Api/Controllers/NotificationsController.cs new file mode 100644 index 0000000..4cbb597 --- /dev/null +++ b/backend/UniVerse.Api/Controllers/NotificationsController.cs @@ -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; + +/// Отправка и планирование уведомлений через доступные каналы. +[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"); + + /// Получить уведомления текущего пользователя. + /// Параметры пагинации. + /// Токен отмены запроса. + /// Список уведомлений. + /// Требуется аутентификация. + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetMine( + [FromQuery] PaginationRequest pagination, + CancellationToken cancellationToken) => + Ok(await _notifications.GetUserNotificationsAsync(CurrentUserId, pagination, cancellationToken)); + + /// Отметить все уведомления текущего пользователя как прочитанные. + /// Токен отмены запроса. + /// Уведомления отмечены прочитанными. + /// Требуется аутентификация. + [HttpPatch("read-all")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MarkAllRead(CancellationToken cancellationToken) + { + await _notifications.MarkAllReadAsync(CurrentUserId, cancellationToken); + return NoContent(); + } + + /// Отправить уведомление немедленно. + /// + /// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`. + /// + /// Канал, получатель, тема и текст уведомления. + /// Уведомление принято к отправке. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpPost("send")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task 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(); + } + + /// Запланировать отложенную отправку уведомления через Quartz.NET. + /// Уведомление и момент отправки. + /// Уведомление поставлено в очередь Quartz.NET. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpPost("schedule")] + [ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken) + { + var response = await _notifications.ScheduleAsync(request, cancellationToken); + return Accepted(response); + } +} diff --git a/backend/UniVerse.Api/Controllers/ReviewsController.cs b/backend/UniVerse.Api/Controllers/ReviewsController.cs index 1f59f1c..90790c3 100644 --- a/backend/UniVerse.Api/Controllers/ReviewsController.cs +++ b/backend/UniVerse.Api/Controllers/ReviewsController.cs @@ -7,40 +7,161 @@ using System.Security.Claims; namespace UniVerse.Api.Controllers; +/// Отзывы студентов на лекции с LLM-анализом и модерацией. [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"); + + /// Создать отзыв к лекции. + /// + /// Только Student. После создания отзыв отправляется на LLM-анализ + /// (статус `Pending`). LLM оценивает содержательность и начисляет монеты + /// скрытно от пользователя. + /// + /// ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва. + /// Отзыв создан и поставлен в очередь на LLM-анализ. + /// Требуется аутентификация. + /// Требуется роль Student. + /// Лекция не найдена. + /// Студент уже оставил отзыв к этой лекции. [Authorize(Roles = "Student")] [HttpPost] + [ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task> Create([FromBody] CreateReviewRequest req) => CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req)); + /// Получить список всех отзывов. + /// Только Admin. Возвращает все отзывы независимо от LLM-статуса. + /// Параметры фильтрации и пагинации. + /// Список всех отзывов (пагинированный). + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task List([FromQuery] ReviewFilterRequest filter) => + Ok(await _reviews.GetAllAsync(filter)); + + /// Получить текущий промпт LLM-анализа отзывов. + /// Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт. + /// Текущий шаблон промпта. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpGet("llm-prompt")] + [ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> GetLlmPrompt() => + Ok(await _reviewPrompts.GetAsync()); + + /// Обновить промпт LLM-анализа отзывов. + /// Только Admin. Промпт применяется к следующим анализам и ручным повторам. + /// Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}. + /// Сохранённый шаблон промпта. + /// Промпт пустой или не содержит обязательные плейсхолдеры. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpPut("llm-prompt")] + [ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UpdateLlmPrompt([FromBody] UpdateReviewPromptRequest request) => + Ok(await _reviewPrompts.UpdateAsync(request)); + + /// Получить отзыв по ID. + /// Только Admin или Teacher. + /// ID отзыва. + /// Данные отзыва (включая LLM-статус и сентимент). + /// Требуется аутентификация. + /// Требуется роль Admin или Teacher. + /// Отзыв не найден. + [Authorize(Roles = "Admin,Teacher")] [HttpGet("{id:int}")] + [ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get(int id) => Ok(await _reviews.GetByIdAsync(id)); + /// Обновить отзыв. + /// + /// Разрешено любому авторизованному пользователю, но сервис проверяет владельца. + /// Изменение текста сбрасывает LLM-статус в `Pending` (повторный анализ). + /// + /// ID отзыва. + /// Новая оценка и/или текст. + /// Обновлённые данные отзыва. + /// Требуется аутентификация. + /// Отзыв принадлежит другому пользователю. + /// Отзыв не найден. [HttpPut("{id:int}")] + [ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Update(int id, [FromBody] UpdateReviewRequest req) => Ok(await _reviews.UpdateAsync(id, CurrentUserId, req)); + /// Удалить отзыв. + /// Владелец может удалить свой отзыв. Admin может удалить любой. + /// ID отзыва. + /// Отзыв удалён. + /// Требуется аутентификация. + /// Нет прав на удаление (не владелец и не Admin). + /// Отзыв не найден. [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Delete(int id) { await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin")); return NoContent(); } - [Authorize(Roles = "Admin")] - [HttpGet("pending")] - public async Task Pending([FromQuery] PaginationRequest pagination) => - Ok(await _reviews.GetPendingAsync(pagination)); - + /// Запустить повторный LLM-анализ отзыва. + /// + /// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его + /// на повторную обработку. + /// + /// ID отзыва. + /// Повторный анализ запланирован. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Отзыв не найден. [Authorize(Roles = "Admin")] [HttpPost("{id:int}/reanalyze")] - public async Task Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); } + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Reanalyze(int id) + { + await _reviews.ReanalyzeAsync(id); + return NoContent(); + } } diff --git a/backend/UniVerse.Api/Controllers/SyncController.cs b/backend/UniVerse.Api/Controllers/SyncController.cs index 51754a0..5dc9607 100644 --- a/backend/UniVerse.Api/Controllers/SyncController.cs +++ b/backend/UniVerse.Api/Controllers/SyncController.cs @@ -5,28 +5,75 @@ using UniVerse.Application.Interfaces; namespace UniVerse.Api.Controllers; +/// Синхронизация данных из внешней системы расписания Modeus (только Admin). [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; + /// Запустить синхронизацию расписания лекций из Modeus. + /// + /// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных + /// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду, + /// размеру выборки, аудиториям, участникам, реализациям курсов/циклов, + /// специальностям, годам набора, профилям, учебным планам и типам занятий. + /// + /// Параметры поиска событий во внешнем сервисе расписания. + /// Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей. + /// Требуется аутентификация. + /// Требуется роль Admin. [HttpPost("schedule")] + [ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> SyncSchedule([FromBody] SyncScheduleRequest req) => Ok(await _sync.SyncScheduleAsync(req)); + /// Получить статус последней синхронизации. + /// Только Admin. Возвращает время и результат последней успешной синхронизации. + /// Статус синхронизации. + /// Требуется аутентификация. + /// Требуется роль Admin. [HttpGet("status")] + [ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Status() => Ok(await _sync.GetLastSyncStatusAsync()); + /// Синхронизировать аудитории (локации) из Modeus. + /// + /// Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт + /// соответствующие записи в таблице locations. + /// + /// Результат синхронизации аудиторий. + /// Требуется аутентификация. + /// Требуется роль Admin. [HttpPost("rooms")] + [ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> SyncRooms() => Ok(await _sync.SyncRoomsAsync()); + /// Поиск преподавателей в Modeus по ФИО. + /// + /// Только Admin. Ищет преподавателей через внешнее API и возвращает список + /// для ручного импорта. Найденные преподаватели не создаются автоматически. + /// + /// Полное имя или часть имени преподавателя для поиска. + /// Список найденных преподавателей. + /// Требуется аутентификация. + /// Требуется роль Admin. [HttpPost("employees")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task SearchEmployees([FromQuery] string fullname) => Ok(await _sync.SearchEmployeesAsync(fullname)); - } diff --git a/backend/UniVerse.Api/Controllers/TagsController.cs b/backend/UniVerse.Api/Controllers/TagsController.cs index 27a6bd7..c9021a7 100644 --- a/backend/UniVerse.Api/Controllers/TagsController.cs +++ b/backend/UniVerse.Api/Controllers/TagsController.cs @@ -6,35 +6,101 @@ using UniVerse.Domain.Enums; namespace UniVerse.Api.Controllers; +/// Управление тегами для категоризации курсов (институты, факультеты, темы и др.). [ApiController] [Route("api/v1/tags")] [Authorize] +[Produces("application/json")] public class TagsController : ControllerBase { private readonly ITagService _tags; + public TagsController(ITagService tags) => _tags = tags; + /// Получить список тегов с опциональной фильтрацией по типу и родителю. + /// Тип тега: Institute, Faculty, Subject, Organization, Topic, Other. + /// ID родительского тега (фильтрация дочерних). + /// Список тегов. + /// Требуется аутентификация. [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) => Ok(await _tags.GetAllAsync(type, parentId)); + /// Получить тег по ID. + /// ID тега. + /// Данные тега. + /// Требуется аутентификация. + /// Тег не найден. [HttpGet("{id:int}")] + [ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get(int id) => Ok(await _tags.GetByIdAsync(id)); + /// Получить иерархическое дерево всех тегов. + /// + /// Возвращает корневые теги с вложенными дочерними тегами. + /// Полезно для построения фильтрующих UI-компонентов. + /// + /// Иерархический список тегов. + /// Требуется аутентификация. [HttpGet("tree")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task GetTree() => Ok(await _tags.GetTreeAsync()); + /// Создать новый тег. + /// Только Admin. + /// Название, тип и опциональный родительский тег. + /// Тег создан. + /// Требуется аутентификация. + /// Требуется роль Admin. [Authorize(Roles = "Admin")] [HttpPost] + [ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> Create([FromBody] CreateTagRequest req) => CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req)); + /// Обновить тег по ID. + /// Только Admin. + /// ID тега. + /// Новое название, тип и/или родительский тег. + /// Обновлённые данные тега. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Тег не найден. [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] + [ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Update(int id, [FromBody] UpdateTagRequest req) => Ok(await _tags.UpdateAsync(id, req)); + /// Удалить тег по ID. + /// + /// Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`). + /// Дочерние теги остаются, но их `parentId` становится null. + /// + /// ID тега. + /// Тег удалён. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Тег не найден. [Authorize(Roles = "Admin")] [HttpDelete("{id:int}")] - public async Task Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); } + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + await _tags.DeleteAsync(id); + return NoContent(); + } } diff --git a/backend/UniVerse.Api/Controllers/UsersController.cs b/backend/UniVerse.Api/Controllers/UsersController.cs index 703ce99..6a15161 100644 --- a/backend/UniVerse.Api/Controllers/UsersController.cs +++ b/backend/UniVerse.Api/Controllers/UsersController.cs @@ -8,71 +8,269 @@ using System.Security.Claims; namespace UniVerse.Api.Controllers; +/// Управление пользователями, профилями и геймификацией. [ApiController] [Route("api/v1/users")] [Authorize] +[Produces("application/json")] public class UsersController : ControllerBase { - private readonly IUserService _users; - private readonly IReviewService _reviews; + 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); + + /// Получить профиль текущего пользователя. + /// Данные текущего пользователя. + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpGet("me")] + [ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetMe() => + Ok(ToCurrentUserDto(await _users.GetByIdAsync(CurrentUserId))); + + /// Обновить профиль текущего пользователя (displayName, avatarUrl). + /// Обновляемые поля профиля. + /// Обновлённые данные текущего пользователя. + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpPut("me")] + [ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateMe([FromBody] UpdateUserRequest req) => + Ok(ToCurrentUserDto(await _users.UpdateProfileAsync(CurrentUserId, req))); + + /// Получить статистику текущего пользователя. + /// Статистика текущего пользователя. + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpGet("me/stats")] + [ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> MyStats() => + Ok(await _users.GetStatsAsync(CurrentUserId)); + + /// Получить список записей текущего пользователя на лекции. + /// Параметры пагинации. + /// Список записей (пагинированный). + /// Требуется аутентификация. + /// Пользователь не найден. + [HttpGet("me/enrollments")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task MyEnrollments([FromQuery] PaginationRequest pagination) => + Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination)); + + /// Получить отзывы текущего пользователя. + /// Параметры пагинации. + /// Список отзывов (пагинированный). + /// Требуется аутентификация. + [HttpGet("me/reviews")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MyReviews([FromQuery] PaginationRequest pagination) => + Ok(await _reviews.GetByUserAsync(CurrentUserId, pagination)); + + /// Получить достижения текущего пользователя. + /// Список полученных достижений. + /// Требуется аутентификация. + [HttpGet("me/achievements")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MyAchievements() => + Ok(await _gamification.GetUserAchievementsAsync(CurrentUserId)); + + /// Получить историю транзакций монет текущего пользователя. + /// Параметры пагинации. + /// История транзакций (пагинированная). + /// Требуется аутентификация. + [HttpGet("me/transactions")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task MyTransactions([FromQuery] PaginationRequest pagination) => + Ok(await _gamification.GetTransactionsAsync(CurrentUserId, pagination)); + + /// Получить профиль пользователя по ID. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me. + /// ID пользователя. + /// Данные пользователя. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}")] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Get(int id) => Ok(await _users.GetByIdAsync(id)); + /// Обновить профиль пользователя (displayName, avatarUrl). + /// Только Admin. Для текущего пользователя используйте PUT /api/v1/users/me. + /// ID пользователя. + /// Обновляемые поля профиля. + /// Обновлённые данные пользователя. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpPut("{id:int}")] - public async Task> 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> Update(int id, [FromBody] UpdateUserRequest req) => + Ok(await _users.UpdateProfileAsync(id, req)); + /// Получить статистику пользователя (XP, монеты, уровень, посещения). + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats. + /// ID пользователя. + /// Статистика пользователя. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/stats")] + [ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> Stats(int id) => Ok(await _users.GetStatsAsync(id)); + /// Получить список записей пользователя на лекции. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments. + /// ID пользователя. + /// Параметры пагинации. + /// Список записей (пагинированный). + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Пользователь не найден. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/enrollments")] - public async Task 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), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Enrollments(int id, [FromQuery] PaginationRequest pagination) => + Ok(await _users.GetEnrollmentsAsync(id, pagination)); + /// Получить отзывы пользователя. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews. + /// ID пользователя. + /// Параметры пагинации. + /// Список отзывов (пагинированный). + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/reviews")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Reviews(int id, [FromQuery] PaginationRequest pagination) => Ok(await _reviews.GetByUserAsync(id, pagination)); + /// Получить достижения пользователя. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements. + /// ID пользователя. + /// Список полученных достижений. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/achievements")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Achievements(int id) => Ok(await _gamification.GetUserAchievementsAsync(id)); + /// Получить историю транзакций монет пользователя. + /// Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/transactions. + /// ID пользователя. + /// Параметры пагинации. + /// История транзакций (пагинированная). + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] [HttpGet("{id:int}/transactions")] - public async Task Transactions(int id, [FromQuery] PaginationRequest pagination) - { - if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid(); - return Ok(await _gamification.GetTransactionsAsync(id, pagination)); - } + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Transactions(int id, [FromQuery] PaginationRequest pagination) => + Ok(await _gamification.GetTransactionsAsync(id, pagination)); + /// Получить список всех пользователей с фильтрацией и пагинацией. + /// Только Admin. + /// Параметры фильтрации (поиск, роль, активность) и пагинации. + /// Список пользователей (пагинированный). + /// Требуется аутентификация. + /// Требуется роль Admin. [Authorize(Roles = "Admin")] [HttpGet] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task GetAll([FromQuery] UserFilterRequest filter) => Ok(await _users.GetAllAsync(filter)); + /// Изменить набор ролей пользователя. + /// Только Admin. Доступные роли: Student, Teacher, Admin. + /// ID пользователя. + /// Новый набор ролей пользователя. + /// Роли успешно изменены. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Пользователь не найден. [Authorize(Roles = "Admin")] [HttpPatch("{id:int}/role")] - public async Task SetRole(int id, [FromBody] UserRole role) + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetRole(int id, [FromBody] IReadOnlyCollection 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(); } + /// Активировать или деактивировать аккаунт пользователя. + /// Только Admin. Деактивированный пользователь не может войти в систему. + /// ID пользователя. + /// true — активировать, false — деактивировать. + /// Статус успешно изменён. + /// Требуется аутентификация. + /// Требуется роль Admin. + /// Пользователь не найден. [Authorize(Roles = "Admin")] [HttpPatch("{id:int}/active")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task SetActive(int id, [FromBody] bool isActive) { await _users.SetActiveAsync(id, isActive); diff --git a/backend/UniVerse.Api/Filters/AuthorizeOperationFilter.cs b/backend/UniVerse.Api/Filters/AuthorizeOperationFilter.cs new file mode 100644 index 0000000..e7d7a65 --- /dev/null +++ b/backend/UniVerse.Api/Filters/AuthorizeOperationFilter.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace UniVerse.Api.Filters; + +/// +/// 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. +/// +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().Any(); + if (hasAllowAnonymous) + return; // completely public — no lock icon + + var authorizeAttributes = allAttributes.OfType().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] = [] + }); + } +} diff --git a/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs b/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs index bd13b7e..17c490f 100644 --- a/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -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"), diff --git a/backend/UniVerse.Api/Options/ReviewAnalysisOptions.cs b/backend/UniVerse.Api/Options/ReviewAnalysisOptions.cs new file mode 100644 index 0000000..28704d3 --- /dev/null +++ b/backend/UniVerse.Api/Options/ReviewAnalysisOptions.cs @@ -0,0 +1,8 @@ +namespace UniVerse.Api.Options; + +public class ReviewAnalysisOptions +{ + public const string SectionName = "Llm:ReviewAnalysis"; + + public int MaxConcurrentProcessing { get; set; } = 1; +} diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 4aeda3a..a9d76c9 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -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("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(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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddTransient(); +builder.Services.Configure(builder.Configuration.GetSection("Email:Smtp")); +builder.Services.AddOptions() + .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(client => @@ -96,7 +139,11 @@ builder.Services.AddHttpClient(client => }); // --- Background Services --- -builder.Services.AddHostedService(); +if (!isOpenApiGeneration) +{ + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); +} // --- Controllers --- builder.Services.AddControllers() @@ -112,46 +159,73 @@ builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo { - Title = "UniVerse API", - Version = "v1", - Description = "University schedule, reviews, and gamification platform" + Title = "UniVerse API", + Version = "v1", + 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", - Type = SecuritySchemeType.Http, - Scheme = "bearer", + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", BearerFormat = "JWT", - In = ParameterLocation.Header, - Description = "Enter your JWT token" + In = ParameterLocation.Header, + 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() - }; - }); + // 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(); }); var app = builder.Build(); +if (useAspire) +{ + app.MapDefaultEndpoints(); +} + // --- Middleware Pipeline --- app.UseMiddleware(); app.UseMiddleware(); 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(); \ No newline at end of file +app.Run(); diff --git a/backend/UniVerse.Api/Properties/launchSettings.json b/backend/UniVerse.Api/Properties/launchSettings.json index 385142c..5648baf 100644 --- a/backend/UniVerse.Api/Properties/launchSettings.json +++ b/backend/UniVerse.Api/Properties/launchSettings.json @@ -4,7 +4,8 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, + "launchUrl": "api/docs", "applicationUrl": "http://localhost:5019", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj index 86f5e3f..5102384 100644 --- a/backend/UniVerse.Api/UniVerse.Api.csproj +++ b/backend/UniVerse.Api/UniVerse.Api.csproj @@ -7,26 +7,35 @@ UniVerse.Api Linux true + true + true + $(BaseIntermediateOutputPath)openapi + --file-name openapi + true + + $(NoWarn);1591 - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - - + + + + + @@ -35,4 +44,14 @@ + + + + diff --git a/backend/UniVerse.Api/appsettings.Development.json b/backend/UniVerse.Api/appsettings.Development.json index 3e1a225..d1699dd 100644 --- a/backend/UniVerse.Api/appsettings.Development.json +++ b/backend/UniVerse.Api/appsettings.Development.json @@ -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" } -} +} \ No newline at end of file diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json index 461626d..ede48ed 100644 --- a/backend/UniVerse.Api/appsettings.json +++ b/backend/UniVerse.Api/appsettings.json @@ -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" + } } } diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json new file mode 100644 index 0000000..f4a4213 --- /dev/null +++ b/backend/UniVerse.Api/openapi.json @@ -0,0 +1,6340 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "UniVerse API", + "description": "REST API веб-платформы UniVerse.\n\nАутентификация: JWT Bearer (получить через `POST /api/v1/auth/login/microsoft` или `POST /api/v1/auth/login/dev` в Development).", + "contact": { + "name": "UniVerse Dev" + }, + "version": "v1" + }, + "paths": { + "/api/v1/achievements": { + "get": { + "tags": [ + "Achievements" + ], + "summary": "Получить список всех достижений.", + "description": "Возвращает определения достижений (без информации о получении конкретным пользователем).\n Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements.\n\n**Required:** any authenticated user", + "responses": { + "200": { + "description": "Список достижений.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AchievementDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "post": { + "tags": [ + "Achievements" + ], + "summary": "Создать новое достижение.", + "description": "Только Admin. Достижения автоматически присваиваются студентам при выполнении условий.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Название, описание, иконка, награда в XP/монетах и условие получения.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAchievementRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateAchievementRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateAchievementRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Достижение создано.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AchievementDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/achievements/{id}": { + "get": { + "tags": [ + "Achievements" + ], + "summary": "Получить достижение по ID.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID достижения.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Данные достижения.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AchievementDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Достижение не найдено.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Achievements" + ], + "summary": "Обновить достижение по ID.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID достижения.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Обновляемые поля достижения.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAchievementRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAchievementRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateAchievementRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные достижения.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AchievementDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Достижение не найдено.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "delete": { + "tags": [ + "Achievements" + ], + "summary": "Удалить достижение по ID.", + "description": "Только Admin. Удаление не отзывает достижение у уже получивших его пользователей.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID достижения.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Достижение удалено." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Достижение не найдено.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/auth/login/microsoft": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Вход через Microsoft Entra ID (SPA/PKCE flow).", + "description": "Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда\nполученный authorization code. В ответ возвращается пара JWT-токенов;\nrefresh token устанавливается в HttpOnly cookie.", + "requestBody": { + "description": "Authorization code и redirect URI из Microsoft OAuth2.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginMicrosoftRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LoginMicrosoftRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/LoginMicrosoftRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Успешный вход — возвращает access token и данные пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "400": { + "description": "Неверный или просроченный authorization code.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "get": { + "tags": [ + "Auth" + ], + "summary": "Инициация server-driven входа через Microsoft (редирект-flow).", + "description": "Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state\nи редиректит пользователя на `login.microsoftonline.com`.\nПосле успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.", + "parameters": [ + { + "name": "returnUrl", + "in": "query", + "description": "URL для редиректа после успешного входа (опционально).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Редирект на Microsoft authorize endpoint." + }, + "500": { + "description": "Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют)." + } + } + } + }, + "/api/v1/auth/callback/microsoft": { + "get": { + "tags": [ + "Auth" + ], + "summary": "OAuth2 callback — обмен code на токены (server-driven flow).", + "description": "Microsoft редиректит браузер сюда после успешного входа.\nBackend валидирует CSRF state, обменивает code на токены,\nустанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.", + "parameters": [ + { + "name": "code", + "in": "query", + "description": "Authorization code от Microsoft.", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "in": "query", + "description": "CSRF state для верификации.", + "schema": { + "type": "string" + } + }, + { + "name": "error", + "in": "query", + "description": "Код ошибки от Microsoft (если вход не удался).", + "schema": { + "type": "string" + } + }, + { + "name": "error_description", + "in": "query", + "description": "Описание ошибки от Microsoft.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "302": { + "description": "Успешный вход — редирект на returnUrl с токеном в URL-фрагменте." + }, + "400": { + "description": "Отсутствует authorization code.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Ошибка от Microsoft или невалидный CSRF state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/login/dev": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Dev-only вход без OAuth (только в Development-окружении).", + "description": "Создаёт или находит пользователя по email без реального OAuth flow.\nВозвращает 404 в Production и Staging.", + "requestBody": { + "description": "Email, отображаемое имя и роль тестового пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DevLoginRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DevLoginRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DevLoginRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Успешный вход.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "404": { + "description": "Endpoint недоступен вне Development.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Обновление access token по refresh token из HttpOnly cookie.", + "description": "Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).\nВозвращает новую пару токенов и обновляет cookie.", + "responses": { + "200": { + "description": "Новая пара токенов.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "401": { + "description": "Refresh token отсутствует, просрочен или отозван.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Аккаунт деактивирован или refresh token недействителен.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Выход из системы — отзыв refresh token.", + "description": "Инвалидирует текущий refresh token в БД и удаляет cookie.\nПосле этого вызова access token остаётся валидным до истечения его TTL (30 минут).\n\n**Required:** any authenticated user", + "responses": { + "204": { + "description": "Выход выполнен успешно." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Получение профиля текущего авторизованного пользователя.", + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "Данные текущего пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден в БД (рассинхронизация токена).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/courses": { + "get": { + "tags": [ + "Courses" + ], + "summary": "Получить список курсов с фильтрацией и пагинацией.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "TagId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Search", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "IsSynced", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список курсов (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CourseDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "post": { + "tags": [ + "Courses" + ], + "summary": "Создать новый курс.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Название и описание курса.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCourseRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateCourseRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateCourseRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Курс создан.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CourseDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/courses/{id}": { + "get": { + "tags": [ + "Courses" + ], + "summary": "Получить курс по ID (включая теги).", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID курса.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Данные курса с тегами.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CourseDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Курс не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Courses" + ], + "summary": "Обновить курс по ID.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID курса.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Новое название и/или описание.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCourseRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCourseRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateCourseRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные курса.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CourseDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Курс не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "delete": { + "tags": [ + "Courses" + ], + "summary": "Удалить курс по ID.", + "description": "Только Admin. Удаление курса каскадно удаляет связанные лекции.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID курса.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Курс удалён." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Курс не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/courses/{id}/tags": { + "post": { + "tags": [ + "Courses" + ], + "summary": "Привязать тег к курсу.", + "description": "Только Admin. Тег должен существовать в системе.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID курса.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "ID тега.", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + }, + "text/json": { + "schema": { + "type": "integer", + "format": "int32" + } + }, + "application/*+json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + }, + "responses": { + "204": { + "description": "Тег привязан." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Курс или тег не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Тег уже привязан к курсу.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/courses/{id}/tags/{tagId}": { + "delete": { + "tags": [ + "Courses" + ], + "summary": "Отвязать тег от курса.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID курса.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "tagId", + "in": "path", + "description": "ID тега.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Тег отвязан." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Курс или тег не найден, либо связь не существует.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/lectures": { + "get": { + "tags": [ + "Lectures" + ], + "summary": "Получить каталог лекций с фильтрацией и пагинацией.", + "description": "Включает флаг `isEnrolled` — записан ли текущий пользователь на лекцию.\n\n**Required:** any authenticated user", + "parameters": [ + { + "name": "DateFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "DateTo", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "CourseId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "TeacherId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Format", + "in": "query", + "schema": { + "$ref": "#/components/schemas/LectureFormat" + } + }, + { + "name": "IsOpen", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "TagId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Search", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список лекций (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "post": { + "tags": [ + "Lectures" + ], + "summary": "Создать новую лекцию.", + "description": "Только Admin. Курс задаётся при создании и не может быть изменён.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Данные лекции: курс, преподаватель, локация, время, формат, вместимость.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLectureRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateLectureRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateLectureRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Лекция создана.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/lectures/{id}": { + "get": { + "tags": [ + "Lectures" + ], + "summary": "Получить детальную карточку лекции по ID.", + "description": "Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию.\n\n**Required:** any authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Детальные данные лекции.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDetailDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Lectures" + ], + "summary": "Обновить лекцию по ID.", + "description": "Admin или Teacher. CourseId изменить нельзя.\n\n**Required roles:** Admin, Teacher", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Обновляемые поля: преподаватель, локация, время, формат, описание.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLectureRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLectureRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateLectureRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные лекции.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin или Teacher.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "delete": { + "tags": [ + "Lectures" + ], + "summary": "Удалить лекцию по ID.", + "description": "Только Admin. Каскадно удаляет записи и отзывы.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Лекция удалена." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/lectures/{id}/enroll": { + "post": { + "tags": [ + "Lectures" + ], + "summary": "Записаться на лекцию.", + "description": "Только Student. Проверяет наличие свободных мест и отсутствие повторной записи.\nПосле посещения начисляются монеты через gamification.\n\n**Required roles:** Student", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Запись выполнена." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Student.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Студент уже записан или мест нет.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "delete": { + "tags": [ + "Lectures" + ], + "summary": "Отменить запись на лекцию.", + "description": "Только Student. Отменить можно только свою запись.\n\n**Required roles:** Student", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Запись отменена." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Student.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция или запись не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/lectures/{id}/attendance/{userId}": { + "patch": { + "tags": [ + "Lectures" + ], + "summary": "Отметить посещение студента на лекции.", + "description": "Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение\nчерез gamification service.\n\n**Required roles:** Admin, Teacher", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "userId", + "in": "path", + "description": "ID студента.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "true — посетил, false — не посетил.", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + }, + "application/*+json": { + "schema": { + "type": "boolean" + } + } + } + }, + "responses": { + "204": { + "description": "Посещение отмечено." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin или Teacher.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция или запись студента не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/lectures/{id}/enrollments": { + "get": { + "tags": [ + "Lectures" + ], + "summary": "Получить список записавшихся студентов на лекцию.", + "description": "Только Admin или Teacher. Включает флаг посещения (`attended`).\n\n**Required roles:** Admin, Teacher", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список записей (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrollmentDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin или Teacher.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/lectures/{id}/reviews": { + "get": { + "tags": [ + "Lectures" + ], + "summary": "Получить отзывы к лекции.", + "description": "Только Admin или Teacher.\n\n**Required roles:** Admin, Teacher", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID лекции.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список отзывов (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin или Teacher.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/locations": { + "get": { + "tags": [ + "Locations" + ], + "summary": "Получить список всех локаций.", + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "Список локаций.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocationDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "post": { + "tags": [ + "Locations" + ], + "summary": "Создать новую локацию.", + "description": "Только Admin. Локации также создаются автоматически при синхронизации с Modeus.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Название, корпус, аудитория и/или адрес.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLocationRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateLocationRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateLocationRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Локация создана.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/locations/{id}": { + "get": { + "tags": [ + "Locations" + ], + "summary": "Получить локацию по ID.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID локации.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Данные локации.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Локация не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Locations" + ], + "summary": "Обновить локацию по ID.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID локации.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Обновляемые поля: название, корпус, аудитория, адрес.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocationRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocationRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные локации.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Локация не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "delete": { + "tags": [ + "Locations" + ], + "summary": "Удалить локацию по ID.", + "description": "Только Admin. При удалении локации у связанных лекций поле `locationId` становится null.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID локации.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Локация удалена." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Локация не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/notifications": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Получить уведомления текущего пользователя.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список уведомлений.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserNotificationDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/notifications/read-all": { + "patch": { + "tags": [ + "Notifications" + ], + "summary": "Отметить все уведомления текущего пользователя как прочитанные.", + "description": "**Required:** any authenticated user", + "responses": { + "204": { + "description": "Уведомления отмечены прочитанными." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/notifications/send": { + "post": { + "tags": [ + "Notifications" + ], + "summary": "Отправить уведомление немедленно.", + "description": "Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Канал, получатель, тема и текст уведомления.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendNotificationRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SendNotificationRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SendNotificationRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Уведомление принято к отправке." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/notifications/schedule": { + "post": { + "tags": [ + "Notifications" + ], + "summary": "Запланировать отложенную отправку уведомления через Quartz.NET.", + "description": "**Required roles:** Admin", + "requestBody": { + "description": "Уведомление и момент отправки.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleNotificationRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleNotificationRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ScheduleNotificationRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Уведомление поставлено в очередь Quartz.NET.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduledNotificationResponse" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/reviews": { + "post": { + "tags": [ + "Reviews" + ], + "summary": "Создать отзыв к лекции.", + "description": "Только Student. После создания отзыв отправляется на LLM-анализ\n(статус `Pending`). LLM оценивает содержательность и начисляет монеты\nскрытно от пользователя.\n\n**Required roles:** Student", + "requestBody": { + "description": "ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReviewRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateReviewRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateReviewRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Отзыв создан и поставлен в очередь на LLM-анализ.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Student.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Лекция не найдена.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Студент уже оставил отзыв к этой лекции.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "get": { + "tags": [ + "Reviews" + ], + "summary": "Получить список всех отзывов.", + "description": "Только Admin. Возвращает все отзывы независимо от LLM-статуса.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "LlmStatus", + "in": "query", + "schema": { + "$ref": "#/components/schemas/ReviewLlmStatus" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список всех отзывов (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/reviews/llm-prompt": { + "get": { + "tags": [ + "Reviews" + ], + "summary": "Получить текущий промпт LLM-анализа отзывов.", + "description": "Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.\n\n**Required roles:** Admin", + "responses": { + "200": { + "description": "Текущий шаблон промпта.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPromptDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Reviews" + ], + "summary": "Обновить промпт LLM-анализа отзывов.", + "description": "Только Admin. Промпт применяется к следующим анализам и ручным повторам.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewPromptRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewPromptRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewPromptRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Сохранённый шаблон промпта.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPromptDto" + } + } + } + }, + "400": { + "description": "Промпт пустой или не содержит обязательные плейсхолдеры.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/reviews/{id}": { + "get": { + "tags": [ + "Reviews" + ], + "summary": "Получить отзыв по ID.", + "description": "Только Admin или Teacher.\n\n**Required roles:** Admin, Teacher", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID отзыва.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Данные отзыва (включая LLM-статус и сентимент).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin или Teacher.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Отзыв не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Reviews" + ], + "summary": "Обновить отзыв.", + "description": "Разрешено любому авторизованному пользователю, но сервис проверяет владельца.\nИзменение текста сбрасывает LLM-статус в `Pending` (повторный анализ).\n\n**Required:** any authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID отзыва.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Новая оценка и/или текст.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные отзыва.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Отзыв принадлежит другому пользователю.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Отзыв не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "delete": { + "tags": [ + "Reviews" + ], + "summary": "Удалить отзыв.", + "description": "Владелец может удалить свой отзыв. Admin может удалить любой.\n\n**Required:** any authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID отзыва.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Отзыв удалён." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Нет прав на удаление (не владелец и не Admin).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Отзыв не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/reviews/{id}/reanalyze": { + "post": { + "tags": [ + "Reviews" + ], + "summary": "Запустить повторный LLM-анализ отзыва.", + "description": "Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его\nна повторную обработку.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID отзыва.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Повторный анализ запланирован." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Отзыв не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/sync/schedule": { + "post": { + "tags": [ + "Sync" + ], + "summary": "Запустить синхронизацию расписания лекций из Modeus.", + "description": "Только Admin. Выполняет upsert лекций и связанных курсов на основе данных\nиз внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду,\nразмеру выборки, аудиториям, участникам, реализациям курсов/циклов,\nспециальностям, годам набора, профилям, учебным планам и типам занятий.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Параметры поиска событий во внешнем сервисе расписания.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncScheduleRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SyncScheduleRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SyncScheduleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncResultDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/sync/status": { + "get": { + "tags": [ + "Sync" + ], + "summary": "Получить статус последней синхронизации.", + "description": "Только Admin. Возвращает время и результат последней успешной синхронизации.\n\n**Required roles:** Admin", + "responses": { + "200": { + "description": "Статус синхронизации.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncStatusDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/sync/rooms": { + "post": { + "tags": [ + "Sync" + ], + "summary": "Синхронизировать аудитории (локации) из Modeus.", + "description": "Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт\nсоответствующие записи в таблице locations.\n\n**Required roles:** Admin", + "responses": { + "200": { + "description": "Результат синхронизации аудиторий.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncResultDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/sync/employees": { + "post": { + "tags": [ + "Sync" + ], + "summary": "Поиск преподавателей в Modeus по ФИО.", + "description": "Только Admin. Ищет преподавателей через внешнее API и возвращает список\nдля ручного импорта. Найденные преподаватели не создаются автоматически.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "fullname", + "in": "query", + "description": "Полное имя или часть имени преподавателя для поиска.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Список найденных преподавателей.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmployeeDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/tags": { + "get": { + "tags": [ + "Tags" + ], + "summary": "Получить список тегов с опциональной фильтрацией по типу и родителю.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "type", + "in": "query", + "description": "Тип тега: Institute, Faculty, Subject, Organization, Topic, Other.", + "schema": { + "$ref": "#/components/schemas/TagType" + } + }, + { + "name": "parentId", + "in": "query", + "description": "ID родительского тега (фильтрация дочерних).", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список тегов.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "post": { + "tags": [ + "Tags" + ], + "summary": "Создать новый тег.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Название, тип и опциональный родительский тег.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTagRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateTagRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateTagRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Тег создан.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/tags/{id}": { + "get": { + "tags": [ + "Tags" + ], + "summary": "Получить тег по ID.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID тега.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Данные тега.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Тег не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Tags" + ], + "summary": "Обновить тег по ID.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID тега.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Новое название, тип и/или родительский тег.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTagRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTagRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateTagRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные тега.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Тег не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "delete": { + "tags": [ + "Tags" + ], + "summary": "Удалить тег по ID.", + "description": "Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`).\nДочерние теги остаются, но их `parentId` становится null.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID тега.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Тег удалён." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Тег не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/tags/tree": { + "get": { + "tags": [ + "Tags" + ], + "summary": "Получить иерархическое дерево всех тегов.", + "description": "Возвращает корневые теги с вложенными дочерними тегами.\nПолезно для построения фильтрующих UI-компонентов.\n\n**Required:** any authenticated user", + "responses": { + "200": { + "description": "Иерархический список тегов.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagTreeDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить профиль текущего пользователя.", + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "Данные текущего пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Users" + ], + "summary": "Обновить профиль текущего пользователя (displayName, avatarUrl).", + "description": "**Required:** any authenticated user", + "requestBody": { + "description": "Обновляемые поля профиля.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные текущего пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/stats": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить статистику текущего пользователя.", + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "Статистика текущего пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatsDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/enrollments": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить список записей текущего пользователя на лекции.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список записей (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/reviews": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить отзывы текущего пользователя.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список отзывов (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/achievements": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить достижения текущего пользователя.", + "description": "**Required:** any authenticated user", + "responses": { + "200": { + "description": "Список полученных достижений.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserAchievementDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/me/transactions": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить историю транзакций монет текущего пользователя.", + "description": "**Required:** any authenticated user", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "История транзакций (пагинированная).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoinTransactionDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить профиль пользователя по ID.", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Данные пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Users" + ], + "summary": "Обновить профиль пользователя (displayName, avatarUrl).", + "description": "Только Admin. Для текущего пользователя используйте PUT /api/v1/users/me.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Обновляемые поля профиля.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Обновлённые данные пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}/stats": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить статистику пользователя (XP, монеты, уровень, посещения).", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Статистика пользователя.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStatsDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}/enrollments": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить список записей пользователя на лекции.", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список записей (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LectureDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}/reviews": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить отзывы пользователя.", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список отзывов (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}/achievements": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить достижения пользователя.", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список полученных достижений.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserAchievementDto" + } + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}/transactions": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить историю транзакций монет пользователя.", + "description": "Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/transactions.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "История транзакций (пагинированная).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoinTransactionDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "Получить список всех пользователей с фильтрацией и пагинацией.", + "description": "Только Admin.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "Search", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Role", + "in": "query", + "schema": { + "$ref": "#/components/schemas/UserRole" + } + }, + { + "name": "IsActive", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Список пользователей (пагинированный).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDtoPagedResult" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}/role": { + "patch": { + "tags": [ + "Users" + ], + "summary": "Изменить набор ролей пользователя.", + "description": "Только Admin. Доступные роли: Student, Teacher, Admin.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "Новый набор ролей пользователя.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRole" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRole" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRole" + } + } + } + } + }, + "responses": { + "204": { + "description": "Роли успешно изменены." + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + }, + "/api/v1/users/{id}/active": { + "patch": { + "tags": [ + "Users" + ], + "summary": "Активировать или деактивировать аккаунт пользователя.", + "description": "Только Admin. Деактивированный пользователь не может войти в систему.\n\n**Required roles:** Admin", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID пользователя.", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "description": "true — активировать, false — деактивировать.", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + }, + "application/*+json": { + "schema": { + "type": "boolean" + } + } + } + }, + "responses": { + "204": { + "description": "Статус успешно изменён." + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Пользователь не найден.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + } + } + }, + "components": { + "schemas": { + "AchievementDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "iconUrl": { + "type": "string", + "nullable": true + }, + "xpReward": { + "type": "integer", + "format": "int32" + }, + "coinReward": { + "type": "integer", + "format": "int32" + }, + "condition": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "AuthResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "nullable": true + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/UserAuthDto" + } + }, + "additionalProperties": false + }, + "CoinTransactionDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "amount": { + "type": "integer", + "format": "int32" + }, + "type": { + "$ref": "#/components/schemas/CoinTransactionType" + }, + "reviewId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "achievementId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "CoinTransactionDtoPagedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CoinTransactionDto" + }, + "nullable": true + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "CoinTransactionType": { + "enum": [ + "ReviewReward", + "AchievementReward", + "AttendanceReward", + "AdminAdjustment" + ], + "type": "string" + }, + "CourseDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "isSynced": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + }, + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "CourseDtoPagedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CourseDto" + }, + "nullable": true + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "CreateAchievementRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "iconUrl": { + "type": "string", + "nullable": true + }, + "xpReward": { + "type": "integer", + "format": "int32" + }, + "coinReward": { + "type": "integer", + "format": "int32" + }, + "condition": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateCourseRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateLectureRequest": { + "type": "object", + "properties": { + "courseId": { + "type": "integer", + "format": "int32" + }, + "teacherId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "locationId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "format": { + "$ref": "#/components/schemas/LectureFormat" + }, + "startsAt": { + "type": "string", + "format": "date-time" + }, + "endsAt": { + "type": "string", + "format": "date-time" + }, + "isOpen": { + "type": "boolean" + }, + "maxEnrollments": { + "type": "integer", + "format": "int32" + }, + "onlineUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateLocationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "building": { + "type": "string", + "nullable": true + }, + "room": { + "type": "string", + "nullable": true + }, + "address": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateReviewRequest": { + "type": "object", + "properties": { + "lectureId": { + "type": "integer", + "format": "int32" + }, + "rating": { + "$ref": "#/components/schemas/ReviewRating" + }, + "text": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "CreateTagRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "type": { + "$ref": "#/components/schemas/TagType" + }, + "parentId": { + "type": "integer", + "format": "int32", + "nullable": true + } + }, + "additionalProperties": false + }, + "CurrentUserDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "email": { + "type": "string", + "nullable": true + }, + "displayName": { + "type": "string", + "nullable": true + }, + "avatarUrl": { + "type": "string", + "nullable": true + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRole" + }, + "nullable": true + }, + "xp": { + "type": "integer", + "format": "int32" + }, + "coins": { + "type": "integer", + "format": "int32" + }, + "level": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "DevLoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "nullable": true + }, + "displayName": { + "type": "string", + "nullable": true + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRole" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "EmployeeDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "fullName": { + "type": "string", + "nullable": true + }, + "department": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "EnrollmentDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "userId": { + "type": "integer", + "format": "int32" + }, + "userName": { + "type": "string", + "nullable": true + }, + "userEmail": { + "type": "string", + "nullable": true + }, + "attended": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "EnrollmentDtoPagedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrollmentDto" + }, + "nullable": true + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "EnrollmentSlotRuleDto": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "format": "int32" + }, + "slots": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "LectureDetailDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "courseId": { + "type": "integer", + "format": "int32" + }, + "courseName": { + "type": "string", + "nullable": true + }, + "teacherId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "teacherName": { + "type": "string", + "nullable": true + }, + "locationId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "locationName": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "format": { + "$ref": "#/components/schemas/LectureFormat" + }, + "startsAt": { + "type": "string", + "format": "date-time" + }, + "endsAt": { + "type": "string", + "format": "date-time" + }, + "isOpen": { + "type": "boolean" + }, + "maxEnrollments": { + "type": "integer", + "format": "int32" + }, + "enrollmentsCount": { + "type": "integer", + "format": "int32" + }, + "onlineUrl": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "isEnrolled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "LectureDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "courseId": { + "type": "integer", + "format": "int32" + }, + "courseName": { + "type": "string", + "nullable": true + }, + "teacherId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "teacherName": { + "type": "string", + "nullable": true + }, + "locationId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "locationName": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "format": { + "$ref": "#/components/schemas/LectureFormat" + }, + "startsAt": { + "type": "string", + "format": "date-time" + }, + "endsAt": { + "type": "string", + "format": "date-time" + }, + "isOpen": { + "type": "boolean" + }, + "maxEnrollments": { + "type": "integer", + "format": "int32" + }, + "enrollmentsCount": { + "type": "integer", + "format": "int32" + }, + "onlineUrl": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "isEnrolled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "LectureDtoPagedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LectureDto" + }, + "nullable": true + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "LectureFormat": { + "enum": [ + "Online", + "Offline" + ], + "type": "string" + }, + "LocationDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "building": { + "type": "string", + "nullable": true + }, + "room": { + "type": "string", + "nullable": true + }, + "address": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "LoginMicrosoftRequest": { + "type": "object", + "properties": { + "authorizationCode": { + "type": "string", + "nullable": true + }, + "redirectUri": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": { } + }, + "ReviewDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "lectureId": { + "type": "integer", + "format": "int32" + }, + "lectureTitle": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "integer", + "format": "int32" + }, + "userName": { + "type": "string", + "nullable": true + }, + "rating": { + "$ref": "#/components/schemas/ReviewRating" + }, + "text": { + "type": "string", + "nullable": true + }, + "llmStatus": { + "$ref": "#/components/schemas/ReviewLlmStatus" + }, + "sentiment": { + "$ref": "#/components/schemas/ReviewSentiment" + }, + "qualityScore": { + "type": "number", + "format": "double", + "nullable": true + }, + "isInformative": { + "type": "boolean", + "nullable": true + }, + "llmTags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "llmRawOutput": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "ReviewDtoPagedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReviewDto" + }, + "nullable": true + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "ReviewLlmStatus": { + "enum": [ + "Pending", + "Analyzed", + "Rejected" + ], + "type": "string" + }, + "ReviewPromptDto": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, + "ReviewRating": { + "enum": [ + "Like", + "Neutral", + "Dislike" + ], + "type": "string" + }, + "ReviewSentiment": { + "enum": [ + "Positive", + "Neutral", + "Negative" + ], + "type": "string" + }, + "ScheduleNotificationRequest": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "nullable": true + }, + "recipient": { + "type": "string", + "nullable": true + }, + "subject": { + "type": "string", + "nullable": true + }, + "body": { + "type": "string", + "nullable": true + }, + "sendAt": { + "type": "string", + "format": "date-time" + }, + "recipientName": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "ScheduledNotificationResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "nullable": true + }, + "sendAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "SendNotificationRequest": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "nullable": true + }, + "recipient": { + "type": "string", + "nullable": true + }, + "subject": { + "type": "string", + "nullable": true + }, + "body": { + "type": "string", + "nullable": true + }, + "recipientName": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "SyncResultDto": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "format": "int32" + }, + "updated": { + "type": "integer", + "format": "int32" + }, + "skipped": { + "type": "integer", + "format": "int32" + }, + "error": { + "type": "string", + "nullable": true + }, + "details": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "SyncScheduleRequest": { + "type": "object", + "properties": { + "specialtyCode": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "timeMin": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "timeMax": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "typeId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "size": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "roomId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "attendeePersonId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "courseUnitRealizationId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "cycleRealizationId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "learningStartYear": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "nullable": true + }, + "profileName": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "curriculumId": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "SyncStatusDto": { + "type": "object", + "properties": { + "lastSyncAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "lastResult": { + "$ref": "#/components/schemas/SyncResultDto" + } + }, + "additionalProperties": false + }, + "TagDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "type": { + "$ref": "#/components/schemas/TagType" + }, + "parentId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "TagTreeDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "type": { + "$ref": "#/components/schemas/TagType" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagTreeDto" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "TagType": { + "enum": [ + "Institute", + "Faculty", + "Subject", + "Organization", + "Topic", + "Other" + ], + "type": "string" + }, + "UpdateAchievementRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "iconUrl": { + "type": "string", + "nullable": true + }, + "xpReward": { + "type": "integer", + "format": "int32" + }, + "coinReward": { + "type": "integer", + "format": "int32" + }, + "condition": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateCourseRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateLectureRequest": { + "type": "object", + "properties": { + "teacherId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "locationId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "format": { + "$ref": "#/components/schemas/LectureFormat" + }, + "startsAt": { + "type": "string", + "format": "date-time" + }, + "endsAt": { + "type": "string", + "format": "date-time" + }, + "isOpen": { + "type": "boolean" + }, + "maxEnrollments": { + "type": "integer", + "format": "int32" + }, + "onlineUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateLocationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "building": { + "type": "string", + "nullable": true + }, + "room": { + "type": "string", + "nullable": true + }, + "address": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateReviewPromptRequest": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateReviewRequest": { + "type": "object", + "properties": { + "rating": { + "$ref": "#/components/schemas/ReviewRating" + }, + "text": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateTagRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "type": { + "$ref": "#/components/schemas/TagType" + }, + "parentId": { + "type": "integer", + "format": "int32", + "nullable": true + } + }, + "additionalProperties": false + }, + "UpdateUserRequest": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "nullable": true + }, + "avatarUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "UserAchievementDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "achievement": { + "$ref": "#/components/schemas/AchievementDto" + }, + "awardedAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "UserAuthDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "email": { + "type": "string", + "nullable": true + }, + "displayName": { + "type": "string", + "nullable": true + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRole" + }, + "nullable": true + } + }, + "additionalProperties": false + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "email": { + "type": "string", + "nullable": true + }, + "displayName": { + "type": "string", + "nullable": true + }, + "avatarUrl": { + "type": "string", + "nullable": true + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRole" + }, + "nullable": true + }, + "isActive": { + "type": "boolean" + }, + "xp": { + "type": "integer", + "format": "int32" + }, + "coins": { + "type": "integer", + "format": "int32" + }, + "level": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "UserDtoPagedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + }, + "nullable": true + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "UserNotificationDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "body": { + "type": "string", + "nullable": true + }, + "isRead": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "UserNotificationDtoPagedResult": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserNotificationDto" + }, + "nullable": true + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "UserRole": { + "enum": [ + "Student", + "Teacher", + "Admin" + ], + "type": "string" + }, + "UserStatsDto": { + "type": "object", + "properties": { + "totalLectures": { + "type": "integer", + "format": "int32" + }, + "attendedLectures": { + "type": "integer", + "format": "int32" + }, + "totalReviews": { + "type": "integer", + "format": "int32" + }, + "xp": { + "type": "integer", + "format": "int32" + }, + "coins": { + "type": "integer", + "format": "int32" + }, + "level": { + "type": "integer", + "format": "int32" + }, + "achievementsCount": { + "type": "integer", + "format": "int32" + }, + "currentLevelXp": { + "type": "integer", + "format": "int32" + }, + "nextLevelXp": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "activeEnrollments": { + "type": "integer", + "format": "int32" + }, + "enrollmentSlotLimit": { + "type": "integer", + "format": "int32" + }, + "enrollmentSlotRules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrollmentSlotRuleDto" + }, + "nullable": true + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "Bearer": { + "type": "http", + "description": "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "tags": [ + { + "name": "Achievements" + }, + { + "name": "Auth" + }, + { + "name": "Courses" + }, + { + "name": "Lectures" + }, + { + "name": "Locations" + }, + { + "name": "Notifications" + }, + { + "name": "Reviews" + }, + { + "name": "Sync" + }, + { + "name": "Tags" + }, + { + "name": "Users" + } + ] +} \ No newline at end of file diff --git a/backend/UniVerse.AppHost/AppHost.cs b/backend/UniVerse.AppHost/AppHost.cs new file mode 100644 index 0000000..b6b7120 --- /dev/null +++ b/backend/UniVerse.AppHost/AppHost.cs @@ -0,0 +1,16 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder + .AddProject("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(); diff --git a/backend/UniVerse.AppHost/Properties/launchSettings.json b/backend/UniVerse.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..6b6e316 --- /dev/null +++ b/backend/UniVerse.AppHost/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/backend/UniVerse.AppHost/UniVerse.AppHost.csproj b/backend/UniVerse.AppHost/UniVerse.AppHost.csproj new file mode 100644 index 0000000..8aa441a --- /dev/null +++ b/backend/UniVerse.AppHost/UniVerse.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + fb90d29a-6c48-471b-b19f-d2f431a5ef38 + + + + + + + + + + + diff --git a/backend/UniVerse.AppHost/appsettings.Development.json b/backend/UniVerse.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/backend/UniVerse.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/UniVerse.AppHost/appsettings.json b/backend/UniVerse.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/backend/UniVerse.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/backend/UniVerse.AppHost/aspire.config.json b/backend/UniVerse.AppHost/aspire.config.json new file mode 100644 index 0000000..a62b559 --- /dev/null +++ b/backend/UniVerse.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "UniVerse.AppHost.csproj" + } +} diff --git a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs index a9a6770..e2519e3 100644 --- a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs +++ b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs @@ -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 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? Roles = null); diff --git a/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs b/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs index 3248165..da1d58f 100644 --- a/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs +++ b/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs @@ -11,3 +11,8 @@ public record CoinTransactionDto( string? Description, DateTime CreatedAt ); + +public record LevelProgressDto( + int CurrentLevelXp, + int? NextLevelXp +); diff --git a/backend/UniVerse.Application/DTOs/Lectures/LectureDtos.cs b/backend/UniVerse.Application/DTOs/Lectures/LectureDtos.cs index 37f3340..24a34ee 100644 --- a/backend/UniVerse.Application/DTOs/Lectures/LectureDtos.cs +++ b/backend/UniVerse.Application/DTOs/Lectures/LectureDtos.cs @@ -19,7 +19,8 @@ public record LectureDto( int MaxEnrollments, int EnrollmentsCount, string? OnlineUrl, - DateTime CreatedAt + DateTime CreatedAt, + bool IsEnrolled = false ); public record LectureDetailDto( diff --git a/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs b/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs new file mode 100644 index 0000000..b2406c2 --- /dev/null +++ b/backend/UniVerse.Application/DTOs/Notifications/NotificationDtos.cs @@ -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? Metadata = null); + +public record SendNotificationRequest( + string Channel, + string Recipient, + string Subject, + string Body, + string? RecipientName = null, + IReadOnlyDictionary? Metadata = null); + +public record ScheduleNotificationRequest( + string Channel, + string Recipient, + string Subject, + string Body, + DateTimeOffset SendAt, + string? RecipientName = null, + IReadOnlyDictionary? Metadata = null); + +public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt); + +public record UserNotificationDto( + int Id, + string Type, + string Title, + string Body, + bool IsRead, + DateTime CreatedAt +); diff --git a/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs b/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs index 416ee41..004e282 100644 --- a/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs +++ b/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs @@ -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); diff --git a/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs b/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs index c55263e..d0720d9 100644 --- a/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs +++ b/backend/UniVerse.Application/DTOs/Sync/SyncDtos.cs @@ -1,13 +1,27 @@ namespace UniVerse.Application.DTOs.Sync; public record SyncScheduleRequest( - string? SpecialtyCode, + IReadOnlyList? SpecialtyCode, DateTime? TimeMin, DateTime? TimeMax, - string? TypeId + IReadOnlyList? TypeId, + int? Size = null, + IReadOnlyList? RoomId = null, + IReadOnlyList? AttendeePersonId = null, + IReadOnlyList? CourseUnitRealizationId = null, + IReadOnlyList? CycleRealizationId = null, + IReadOnlyList? LearningStartYear = null, + IReadOnlyList? ProfileName = null, + IReadOnlyList? 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? Details = null +); public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult); diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index 2a79aa9..c06797f 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -7,7 +7,7 @@ public record UserDto( string Email, string? DisplayName, string? AvatarUrl, - UserRole Role, + IReadOnlyList 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 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 EnrollmentSlotRules ); +public record EnrollmentSlotRuleDto(int Level, int Slots); + public record UpdateUserRequest( string? DisplayName, string? AvatarUrl diff --git a/backend/UniVerse.Application/Interfaces/IAuthService.cs b/backend/UniVerse.Application/Interfaces/IAuthService.cs index 9e33059..4179803 100644 --- a/backend/UniVerse.Application/Interfaces/IAuthService.cs +++ b/backend/UniVerse.Application/Interfaces/IAuthService.cs @@ -5,9 +5,9 @@ namespace UniVerse.Application.Interfaces; public interface IAuthService { - Task LoginWithMicrosoftAsync(string authorizationCode); - Task DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role); + Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null); + Task DevLoginAsync(string email, string? displayName, IReadOnlyCollection roles, string? ipAddress = null); Task RefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken); - Task GetCurrentUserAsync(int userId); + Task GetCurrentUserAsync(int userId); } diff --git a/backend/UniVerse.Application/Interfaces/IGamificationService.cs b/backend/UniVerse.Application/Interfaces/IGamificationService.cs index 387b60c..57386e1 100644 --- a/backend/UniVerse.Application/Interfaces/IGamificationService.cs +++ b/backend/UniVerse.Application/Interfaces/IGamificationService.cs @@ -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 CalculateLevelAsync(int xp); + Task GetLevelProgressAsync(int xp); Task> GetUserAchievementsAsync(int userId); Task> GetTransactionsAsync(int userId, PaginationRequest pagination); } diff --git a/backend/UniVerse.Application/Interfaces/ILectureService.cs b/backend/UniVerse.Application/Interfaces/ILectureService.cs index 2dd11ad..75d12a3 100644 --- a/backend/UniVerse.Application/Interfaces/ILectureService.cs +++ b/backend/UniVerse.Application/Interfaces/ILectureService.cs @@ -5,13 +5,13 @@ namespace UniVerse.Application.Interfaces; public interface ILectureService { - Task> GetAllAsync(LectureFilterRequest filter); + Task> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null); Task GetByIdAsync(int id, int? currentUserId = null); Task CreateAsync(CreateLectureRequest request); - Task UpdateAsync(int id, UpdateLectureRequest request); + Task 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> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination); + Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false); + Task> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false); } diff --git a/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs b/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs index 7f5af0a..47f1fcc 100644 --- a/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs +++ b/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs @@ -3,5 +3,4 @@ namespace UniVerse.Application.Interfaces; public interface ILlmAnalysisService { Task AnalyzeReviewAsync(int reviewId); - Task ProcessPendingReviewsAsync(); } diff --git a/backend/UniVerse.Application/Interfaces/ILlmClient.cs b/backend/UniVerse.Application/Interfaces/ILlmClient.cs index dccefda..88d8a79 100644 --- a/backend/UniVerse.Application/Interfaces/ILlmClient.cs +++ b/backend/UniVerse.Application/Interfaces/ILlmClient.cs @@ -4,7 +4,8 @@ public record LlmReviewAnalysis( double QualityScore, string Sentiment, string[] Tags, - bool IsInformative + bool IsInformative, + string RawOutput ); public interface ILlmClient diff --git a/backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs b/backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs new file mode 100644 index 0000000..e1389ca --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs @@ -0,0 +1,11 @@ +namespace UniVerse.Application.Interfaces; + +public interface IMicrosoftAuthClient +{ + Task ExchangeAuthorizationCodeAsync( + string authorizationCode, + string redirectUri, + CancellationToken cancellationToken = default); +} + +public record MicrosoftTokenResult(string IdToken); diff --git a/backend/UniVerse.Application/Interfaces/INotificationProvider.cs b/backend/UniVerse.Application/Interfaces/INotificationProvider.cs new file mode 100644 index 0000000..383a1e2 --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/INotificationProvider.cs @@ -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); +} diff --git a/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs b/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs new file mode 100644 index 0000000..518696b --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs @@ -0,0 +1,14 @@ +using UniVerse.Application.DTOs.Notifications; + +namespace UniVerse.Application.Interfaces; + +public interface INotificationScheduler +{ + Task ScheduleAsync( + NotificationMessage message, + DateTimeOffset sendAt, + string? jobId = null, + CancellationToken cancellationToken = default); + + Task CancelAsync(string jobId, CancellationToken cancellationToken = default); +} diff --git a/backend/UniVerse.Application/Interfaces/INotificationService.cs b/backend/UniVerse.Application/Interfaces/INotificationService.cs new file mode 100644 index 0000000..4d242b4 --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/INotificationService.cs @@ -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 ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default); + Task CreateUserNotificationAsync(int userId, string type, string title, string body, CancellationToken cancellationToken = default); + Task> GetUserNotificationsAsync(int userId, PaginationRequest pagination, CancellationToken cancellationToken = default); + Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default); +} diff --git a/backend/UniVerse.Application/Interfaces/IReviewAnalysisQueue.cs b/backend/UniVerse.Application/Interfaces/IReviewAnalysisQueue.cs new file mode 100644 index 0000000..6d993ca --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/IReviewAnalysisQueue.cs @@ -0,0 +1,6 @@ +namespace UniVerse.Application.Interfaces; + +public interface IReviewAnalysisQueue +{ + Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default); +} diff --git a/backend/UniVerse.Application/Interfaces/IReviewPromptService.cs b/backend/UniVerse.Application/Interfaces/IReviewPromptService.cs new file mode 100644 index 0000000..77deb86 --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/IReviewPromptService.cs @@ -0,0 +1,9 @@ +using UniVerse.Application.DTOs.Reviews; + +namespace UniVerse.Application.Interfaces; + +public interface IReviewPromptService +{ + Task GetAsync(); + Task UpdateAsync(UpdateReviewPromptRequest request); +} diff --git a/backend/UniVerse.Application/Interfaces/IReviewService.cs b/backend/UniVerse.Application/Interfaces/IReviewService.cs index 8d3330a..38c1823 100644 --- a/backend/UniVerse.Application/Interfaces/IReviewService.cs +++ b/backend/UniVerse.Application/Interfaces/IReviewService.cs @@ -9,8 +9,8 @@ public interface IReviewService Task GetByIdAsync(int id); Task UpdateAsync(int id, int userId, UpdateReviewRequest request); Task DeleteAsync(int id, int userId, bool isAdmin = false); - Task> GetByLectureAsync(int lectureId, PaginationRequest pagination); + Task> GetByLectureAsync(int lectureId, PaginationRequest pagination, int? currentUserId = null, bool isAdmin = false); Task> GetByUserAsync(int userId, PaginationRequest pagination); - Task> GetPendingAsync(PaginationRequest pagination); + Task> GetAllAsync(ReviewFilterRequest filter); Task ReanalyzeAsync(int id); } diff --git a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs index 7558af7..dfad430 100644 --- a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs +++ b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs @@ -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 SearchEventsAsync(SyncScheduleRequest request); Task SearchRoomsAsync(); Task> SearchEmployeeAsync(string fullname); + Task 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 Events); -public record ModeusRoom(string Id, string Name, string? Building); -public record ModeusRoomsResponse(List 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? Events { get; init; } + public ModeusPage? Page { get; init; } + + [JsonIgnore] + public IReadOnlyList EventItems => Embedded?.Events ?? Events ?? []; +} +public class ModeusEventsEmbedded +{ + public List? Events { get; init; } + + [JsonPropertyName("course-unit-realizations")] + public List? CourseUnitRealizations { get; init; } + + [JsonPropertyName("event-rooms")] + public List? EventRooms { get; init; } + + [JsonPropertyName("event-teams")] + public List? EventTeams { get; init; } + + [JsonPropertyName("event-attendees")] + public List? EventAttendees { get; init; } + + public List? Persons { get; init; } + + public List? 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? 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? Rooms { get; init; } + + [JsonIgnore] + public IReadOnlyList RoomItems => Embedded?.Rooms ?? Rooms ?? []; +} public record ModeusEmployee(string? Id, string FullName, string? Department); diff --git a/backend/UniVerse.Application/Interfaces/IUserService.cs b/backend/UniVerse.Application/Interfaces/IUserService.cs index 4625450..7c6b60f 100644 --- a/backend/UniVerse.Application/Interfaces/IUserService.cs +++ b/backend/UniVerse.Application/Interfaces/IUserService.cs @@ -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 GetByIdAsync(int id); Task UpdateProfileAsync(int id, UpdateUserRequest request); Task GetStatsAsync(int id); + Task> GetEnrollmentsAsync(int id, PaginationRequest pagination); Task> GetAllAsync(UserFilterRequest filter); - Task SetRoleAsync(int id, UserRole role); + Task SetRolesAsync(int id, IReadOnlyCollection roles); Task SetActiveAsync(int id, bool isActive); } diff --git a/backend/UniVerse.Application/Mappings/MappingExtensions.cs b/backend/UniVerse.Application/Mappings/MappingExtensions.cs index a332e41..ddd978c 100644 --- a/backend/UniVerse.Application/Mappings/MappingExtensions.cs +++ b/backend/UniVerse.Application/Mappings/MappingExtensions.cs @@ -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 --- diff --git a/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs b/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs new file mode 100644 index 0000000..db01367 --- /dev/null +++ b/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs @@ -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); +} diff --git a/backend/UniVerse.Application/UniVerse.Application.csproj b/backend/UniVerse.Application/UniVerse.Application.csproj index d83e800..d068bc7 100644 --- a/backend/UniVerse.Application/UniVerse.Application.csproj +++ b/backend/UniVerse.Application/UniVerse.Application.csproj @@ -8,12 +8,12 @@ - - - + + + - - + + diff --git a/backend/UniVerse.Domain/Entities/LevelThreshold.cs b/backend/UniVerse.Domain/Entities/LevelThreshold.cs new file mode 100644 index 0000000..53e46b0 --- /dev/null +++ b/backend/UniVerse.Domain/Entities/LevelThreshold.cs @@ -0,0 +1,7 @@ +namespace UniVerse.Domain.Entities; + +public class LevelThreshold +{ + public int Level { get; set; } + public int RequiredXp { get; set; } +} diff --git a/backend/UniVerse.Domain/Entities/Review.cs b/backend/UniVerse.Domain/Entities/Review.cs index 9883dcd..956c999 100644 --- a/backend/UniVerse.Domain/Entities/Review.cs +++ b/backend/UniVerse.Domain/Entities/Review.cs @@ -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; diff --git a/backend/UniVerse.Domain/Entities/ReviewPromptSetting.cs b/backend/UniVerse.Domain/Entities/ReviewPromptSetting.cs new file mode 100644 index 0000000..070cbfe --- /dev/null +++ b/backend/UniVerse.Domain/Entities/ReviewPromptSetting.cs @@ -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; +} diff --git a/backend/UniVerse.Domain/Entities/User.cs b/backend/UniVerse.Domain/Entities/User.cs index d7893de..742e9d2 100644 --- a/backend/UniVerse.Domain/Entities/User.cs +++ b/backend/UniVerse.Domain/Entities/User.cs @@ -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 Roles { get; set; } = new List(); public ICollection Enrollments { get; set; } = new List(); public ICollection Reviews { get; set; } = new List(); public ICollection UserAchievements { get; set; } = new List(); public ICollection CoinTransactions { get; set; } = new List(); + public ICollection Notifications { get; set; } = new List(); public ICollection RefreshTokens { get; set; } = new List(); } diff --git a/backend/UniVerse.Domain/Entities/UserNotification.cs b/backend/UniVerse.Domain/Entities/UserNotification.cs new file mode 100644 index 0000000..0441c02 --- /dev/null +++ b/backend/UniVerse.Domain/Entities/UserNotification.cs @@ -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!; +} diff --git a/backend/UniVerse.Domain/Entities/UserRoleAssignment.cs b/backend/UniVerse.Domain/Entities/UserRoleAssignment.cs new file mode 100644 index 0000000..57d361a --- /dev/null +++ b/backend/UniVerse.Domain/Entities/UserRoleAssignment.cs @@ -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!; +} diff --git a/backend/UniVerse.Domain/Exceptions/BadRequestException.cs b/backend/UniVerse.Domain/Exceptions/BadRequestException.cs new file mode 100644 index 0000000..bfda608 --- /dev/null +++ b/backend/UniVerse.Domain/Exceptions/BadRequestException.cs @@ -0,0 +1,8 @@ +namespace UniVerse.Domain.Exceptions; + +public class BadRequestException : Exception +{ + public BadRequestException(string message) : base(message) + { + } +} diff --git a/backend/UniVerse.Domain/Services/EnrollmentSlotPolicy.cs b/backend/UniVerse.Domain/Services/EnrollmentSlotPolicy.cs new file mode 100644 index 0000000..d99fcc3 --- /dev/null +++ b/backend/UniVerse.Domain/Services/EnrollmentSlotPolicy.cs @@ -0,0 +1,21 @@ +namespace UniVerse.Domain.Services; + +public static class EnrollmentSlotPolicy +{ + private static readonly IReadOnlyList SlotRules = + [ + new(1, 3), + new(3, 5), + new(4, 7) + ]; + + public static IReadOnlyList 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); diff --git a/backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs b/backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs new file mode 100644 index 0000000..a9899ff --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs @@ -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 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 LegacyConditions = new Dictionary + { + ["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(); + 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); +} diff --git a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs index 4280343..f6ba558 100644 --- a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs +++ b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs @@ -10,6 +10,7 @@ public class AppDbContext : DbContext public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Users { get; set; } = null!; + public DbSet UserRoles { get; set; } = null!; public DbSet StudentProfiles { get; set; } = null!; public DbSet TeacherProfiles { get; set; } = null!; public DbSet Courses { get; set; } = null!; @@ -19,9 +20,12 @@ public class AppDbContext : DbContext public DbSet CourseTags { get; set; } = null!; public DbSet LectureEnrollments { get; set; } = null!; public DbSet Reviews { get; set; } = null!; + public DbSet ReviewPromptSettings { get; set; } = null!; public DbSet Achievements { get; set; } = null!; public DbSet UserAchievements { get; set; } = null!; public DbSet CoinTransactions { get; set; } = null!; + public DbSet LevelThresholds { get; set; } = null!; + public DbSet UserNotifications { get; set; } = null!; public DbSet RefreshTokens { get; set; } = null!; static AppDbContext() diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/LevelThresholdConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/LevelThresholdConfiguration.cs new file mode 100644 index 0000000..bf72d44 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/LevelThresholdConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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 } + ); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs index d3d7c8e..f53ccec 100644 --- a/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs +++ b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs @@ -21,6 +21,7 @@ public class ReviewConfiguration : IEntityTypeConfiguration 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()"); diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/ReviewPromptSettingConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewPromptSettingConfiguration.cs new file mode 100644 index 0000000..797a2a5 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewPromptSettingConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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()"); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs index c450fce..fc034d9 100644 --- a/backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs +++ b/backend/UniVerse.Infrastructure/Data/Configurations/TeacherProfileConfiguration.cs @@ -24,5 +24,6 @@ public class TeacherProfileConfiguration : IEntityTypeConfiguration t.UserId).IsUnique(); + builder.HasIndex(t => t.ModeusId).IsUnique().HasFilter("modeus_id IS NOT NULL"); } } diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs index 9297d39..91e7b03 100644 --- a/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserConfiguration.cs @@ -15,7 +15,6 @@ public class UserConfiguration : IEntityTypeConfiguration 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 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); } } diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserNotificationConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserNotificationConfiguration.cs new file mode 100644 index 0000000..8ec221f --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserNotificationConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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 }); + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/UserRoleAssignmentConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/UserRoleAssignmentConfiguration.cs new file mode 100644 index 0000000..f0872f7 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/UserRoleAssignmentConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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"); + } +} diff --git a/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs index 903dc80..8a67f40 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs @@ -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 _logger; - public LlmClient(HttpClient http, IConfiguration config, ILogger logger) + public LlmClient( + HttpClient http, + IConfiguration config, + IReviewPromptService reviewPrompts, + ILogger logger) { - _http = http; _config = config; _logger = logger; + _http = http; + _config = config; + _reviewPrompts = reviewPrompts; + _logger = logger; } public async Task 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(); var content = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!; - var analysis = JsonSerializer.Deserialize(content, + var analysisJson = NormalizeJsonContent(content); + var analysis = JsonSerializer.Deserialize(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); } diff --git a/backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs new file mode 100644 index 0000000..53ceff5 --- /dev/null +++ b/backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs @@ -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 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}"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs index b1e2e24..c55b711 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs @@ -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,23 +24,140 @@ public class ModeusApiClient : IModeusApiClient public async Task 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 + { + ["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() ?? new(new()); + var requestJson = JsonSerializer.Serialize(body); + await EnsureSuccessAsync(response, "Modeus events search", + BuildEventsRequestSummary(requestJson)); + return await ReadJsonAsync(response, "Modeus events search", + BuildEventsRequestSummary(requestJson)) + ?? new ModeusEventsResponse(); } public async Task SearchRoomsAsync() { - var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", new { }); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync() ?? new(new()); + const int pageSize = 100; + var allRooms = new List(); + 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=, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false"); + + var payload = await ReadJsonAsync(response, "Modeus rooms search", + $"name=, 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( + IDictionary body, + string key, + IReadOnlyList? values) + { + if (values is { Count: > 0 }) + body[key] = values; + } + + private static async Task ReadJsonAsync(HttpResponseMessage response, string operation, string requestSummary) + { + var responseBody = await response.Content.ReadAsStringAsync(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? ""; + var contentLength = response.Content.Headers.ContentLength?.ToString() ?? ""; + + 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(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), "...") : value; + public async Task> SearchEmployeeAsync(string fullname) { var response = await _http.GetFromJsonAsync>( $"/api/schedule/searchemployee?fullname={Uri.EscapeDataString(fullname)}"); return response ?? new(); } + + public async Task 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(); + } } diff --git a/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260506134139_Initial.Designer.cs similarity index 99% rename from backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.Designer.cs rename to backend/UniVerse.Infrastructure/Migrations/20260506134139_Initial.Designer.cs index 358add9..b807140 100644 --- a/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.Designer.cs +++ b/backend/UniVerse.Infrastructure/Migrations/20260506134139_Initial.Designer.cs @@ -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 { /// diff --git a/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.cs b/backend/UniVerse.Infrastructure/Migrations/20260506134139_Initial.cs similarity index 99% rename from backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.cs rename to backend/UniVerse.Infrastructure/Migrations/20260506134139_Initial.cs index d6c7761..a2f128a 100644 --- a/backend/UniVerse.Infrastructure/Data/Migrations/20260428124938_Initial.cs +++ b/backend/UniVerse.Infrastructure/Migrations/20260506134139_Initial.cs @@ -4,7 +4,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace UniVerse.Infrastructure.Data.Migrations +namespace UniVerse.Infrastructure.Migrations { /// public partial class Initial : Migration diff --git a/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.Designer.cs new file mode 100644 index 0000000..5f6da46 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.Designer.cs @@ -0,0 +1,979 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CoinReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coin_reward"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ReviewId") + .HasColumnType("integer") + .HasColumnName("review_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("IsSynced") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_synced"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("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("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("TagId") + .HasColumnType("integer") + .HasColumnName("tag_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("IsOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_open"); + + b.Property("LocationId") + .HasColumnType("integer") + .HasColumnName("location_id"); + + b.Property("MaxEnrollments") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("max_enrollments"); + + b.Property("OnlineUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("online_url"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("TeacherId") + .HasColumnType("integer") + .HasColumnName("teacher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("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("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Attended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("attended"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("address"); + + b.Property("Building") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("building"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsInformative") + .HasColumnType("boolean") + .HasColumnName("is_informative"); + + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("LlmStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("llm_status"); + + b.PrimitiveCollection("LlmTags") + .HasColumnType("text[]") + .HasColumnName("llm_tags"); + + b.Property("QualityScore") + .HasColumnType("double precision") + .HasColumnName("quality_score"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Sentiment") + .HasColumnType("integer") + .HasColumnName("sentiment"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnrollmentYear") + .HasColumnType("integer") + .HasColumnName("enrollment_year"); + + b.Property("Faculty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("faculty"); + + b.Property("GroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("group_name"); + + b.Property("Specialty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("specialty"); + + b.Property("StudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("student_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("integer") + .HasColumnName("parent_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("Department") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("department"); + + b.Property("ModeusId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("modeus_id"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("Coins") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coins"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MicrosoftId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("microsoft_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("AwardedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("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 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.cs b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.cs new file mode 100644 index 0000000..2016a04 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260511011508_UserRolesJoinTable.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class UserRolesJoinTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_roles", + columns: table => new + { + user_id = table.Column(type: "integer", nullable: false), + role = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_roles", x => new { x.user_id, x.role }); + table.ForeignKey( + name: "FK_user_roles_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql(""" + INSERT INTO user_roles (user_id, role) + SELECT id, role FROM users; + """); + + migrationBuilder.DropColumn( + name: "role", + table: "users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "role", + table: "users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql(""" + UPDATE users + SET role = COALESCE(( + SELECT MIN(ur.role) + FROM user_roles ur + WHERE ur.user_id = users.id + ), 0); + """); + + migrationBuilder.DropTable( + name: "user_roles"); + + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260512120000_SeedAchievements.cs b/backend/UniVerse.Infrastructure/Migrations/20260512120000_SeedAchievements.cs new file mode 100644 index 0000000..b81e94f --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260512120000_SeedAchievements.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260512120000_SeedAchievements")] + public partial class SeedAchievements : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + UPDATE achievements + SET condition = CASE condition + WHEN 'reviews_1' THEN 'reviews_written:1' + WHEN 'reviews_5' THEN 'reviews_written:5' + WHEN 'reviews_10' THEN 'reviews_written:10' + WHEN 'attended_5' THEN 'lectures_attended:5' + WHEN 'attended_10' THEN 'lectures_attended:10' + ELSE condition + END + WHERE condition IN ('reviews_1', 'reviews_5', 'reviews_10', 'attended_5', 'attended_10'); + """); + + migrationBuilder.Sql(""" + INSERT INTO achievements (id, name, description, icon_url, xp_reward, coin_reward, condition, created_at) + VALUES + (1001, 'Добро пожаловать в UniVerse', 'Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.', 'sparkles', 0, 10, 'first_activity:1', NOW()), + (1002, 'Первый шаг', 'Посетить первую открытую лекцию.', 'book-2', 0, 10, 'lectures_attended:1', NOW()), + (1003, 'Вошел во вкус', 'Посетить 3 открытые лекции.', 'books', 0, 20, 'lectures_attended:3', NOW()), + (1004, 'Постоянный слушатель', 'Посетить 5 открытых лекций.', 'calendar-event', 0, 35, 'lectures_attended:5', NOW()), + (1005, 'Академический марафон', 'Посетить 10 открытых лекций.', 'stopwatch', 0, 60, 'lectures_attended:10', NOW()), + (1006, 'Грандмастер лекций', 'Посетить 25 открытых лекций.', 'trophy', 0, 120, 'lectures_attended:25', NOW()), + (1007, 'Первый отзыв', 'Оставить первый отзыв о посещенной лекции.', 'message-circle', 0, 10, 'reviews_written:1', NOW()), + (1008, 'Голос аудитории', 'Оставить 3 отзыва о лекциях.', 'thumb-up', 0, 25, 'reviews_written:3', NOW()), + (1009, 'Рецензент', 'Оставить 10 отзывов о лекциях.', 'clipboard-list', 0, 70, 'reviews_written:10', NOW()), + (1010, 'Голос перемен', 'Оставить 25 отзывов о лекциях.', 'chart-line', 0, 150, 'reviews_written:25', NOW()), + (1011, 'Смелый выбор', 'Записаться на первую открытую лекцию.', 'calendar', 0, 5, 'lectures_registered:1', NOW()), + (1012, 'План на неделю', 'Иметь 3 активные записи на будущие лекции.', 'calendar-event', 0, 15, 'active_registrations:3', NOW()), + (1013, 'Полный календарь', 'Иметь 5 активных записей на будущие лекции.', 'alarm', 0, 30, 'active_registrations:5', NOW()), + (1014, 'Серия интереса', 'Посещать открытые лекции 3 недели подряд.', 'star', 0, 50, 'attendance_streak_weeks:3', NOW()), + (1015, 'Учебный месяц', 'Посещать открытые лекции 4 недели подряд.', 'sparkles', 0, 80, 'attendance_streak_weeks:4', NOW()), + (1016, 'Без пропусков', 'Посетить 5 лекций, на которые была оформлена запись.', 'circle-check', 0, 40, 'attended_registered:5', NOW()), + (1017, 'Надежный участник', 'Посетить 10 лекций, на которые была оформлена запись.', 'shield', 0, 75, 'attended_registered:10', NOW()), + (1018, 'Капитал знаний', 'Получить 500 монет за активность на платформе.', 'coin', 0, 80, 'coins_earned:500', NOW()), + (1019, 'Новый уровень', 'Достигнуть 2 уровня.', 'star', 0, 25, 'level_reached:2', NOW()), + (1020, 'Уверенный рост', 'Достигнуть 5 уровня.', 'chart-bar', 0, 100, 'level_reached:5', NOW()), + (1021, 'Профиль заполнен', 'Заполнить имя и аватар в профиле.', 'user', 0, 10, 'profile_completed:1', NOW()) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + icon_url = EXCLUDED.icon_url, + xp_reward = EXCLUDED.xp_reward, + coin_reward = EXCLUDED.coin_reward, + condition = EXCLUDED.condition; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM achievements WHERE id BETWEEN 1001 AND 1021;"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260512123000_UserNotifications.cs b/backend/UniVerse.Infrastructure/Migrations/20260512123000_UserNotifications.cs new file mode 100644 index 0000000..f2de9b7 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260512123000_UserNotifications.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260512123000_UserNotifications")] + public partial class UserNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_notifications", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "integer", nullable: false), + type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + title = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + body = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + is_read = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_user_notifications", x => x.id); + table.ForeignKey( + name: "FK_user_notifications_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_user_notifications_user_id_created_at", + table: "user_notifications", + columns: new[] { "user_id", "created_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "user_notifications"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.Designer.cs new file mode 100644 index 0000000..bf61fae --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.Designer.cs @@ -0,0 +1,1107 @@ +// +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("20260517230001_LevelThresholds")] + partial class LevelThresholds + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CoinReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coin_reward"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ReviewId") + .HasColumnType("integer") + .HasColumnName("review_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("IsSynced") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_synced"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("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("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("TagId") + .HasColumnType("integer") + .HasColumnName("tag_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("IsOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_open"); + + b.Property("LocationId") + .HasColumnType("integer") + .HasColumnName("location_id"); + + b.Property("MaxEnrollments") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("max_enrollments"); + + b.Property("OnlineUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("online_url"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("TeacherId") + .HasColumnType("integer") + .HasColumnName("teacher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("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("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Attended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("attended"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.LevelThreshold", b => + { + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("RequiredXp") + .HasColumnType("integer") + .HasColumnName("required_xp"); + + b.HasKey("Level"); + + b.HasIndex("RequiredXp") + .IsUnique(); + + b.ToTable("level_thresholds", null, t => + { + t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + + t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + b.HasData( + new + { + Level = 1, + RequiredXp = 0 + }, + new + { + Level = 2, + RequiredXp = 100 + }, + new + { + Level = 3, + RequiredXp = 300 + }, + new + { + Level = 4, + RequiredXp = 600 + }, + new + { + Level = 5, + RequiredXp = 1000 + }, + new + { + Level = 6, + RequiredXp = 1500 + }, + new + { + Level = 7, + RequiredXp = 2500 + }, + new + { + Level = 8, + RequiredXp = 4000 + }); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("address"); + + b.Property("Building") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("building"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsInformative") + .HasColumnType("boolean") + .HasColumnName("is_informative"); + + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("LlmStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("llm_status"); + + b.PrimitiveCollection("LlmTags") + .HasColumnType("text[]") + .HasColumnName("llm_tags"); + + b.Property("QualityScore") + .HasColumnType("double precision") + .HasColumnName("quality_score"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Sentiment") + .HasColumnType("integer") + .HasColumnName("sentiment"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnrollmentYear") + .HasColumnType("integer") + .HasColumnName("enrollment_year"); + + b.Property("Faculty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("faculty"); + + b.Property("GroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("group_name"); + + b.Property("Specialty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("specialty"); + + b.Property("StudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("student_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("integer") + .HasColumnName("parent_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("Department") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("department"); + + b.Property("ModeusId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("modeus_id"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("Coins") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coins"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MicrosoftId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("microsoft_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("AwardedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("body"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("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.UserNotification", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("Notifications"); + + b.Navigation("RefreshTokens"); + + b.Navigation("Reviews"); + + b.Navigation("Roles"); + + b.Navigation("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.cs b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.cs new file mode 100644 index 0000000..549ff6e --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class LevelThresholds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "level_thresholds", + columns: table => new + { + level = table.Column(type: "integer", nullable: false), + required_xp = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_level_thresholds", x => x.level); + table.CheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + table.CheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + migrationBuilder.InsertData( + table: "level_thresholds", + columns: new[] { "level", "required_xp" }, + values: new object[,] + { + { 1, 0 }, + { 2, 100 }, + { 3, 300 }, + { 4, 600 }, + { 5, 1000 }, + { 6, 1500 }, + { 7, 2500 }, + { 8, 4000 } + }); + + migrationBuilder.CreateIndex( + name: "IX_level_thresholds_required_xp", + table: "level_thresholds", + column: "required_xp", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "level_thresholds"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.Designer.cs new file mode 100644 index 0000000..98d18a1 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.Designer.cs @@ -0,0 +1,1135 @@ +// +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("20260521170452_ReviewPromptSettings")] + partial class ReviewPromptSettings + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CoinReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coin_reward"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ReviewId") + .HasColumnType("integer") + .HasColumnName("review_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("IsSynced") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_synced"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("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("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("TagId") + .HasColumnType("integer") + .HasColumnName("tag_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("IsOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_open"); + + b.Property("LocationId") + .HasColumnType("integer") + .HasColumnName("location_id"); + + b.Property("MaxEnrollments") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("max_enrollments"); + + b.Property("OnlineUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("online_url"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("TeacherId") + .HasColumnType("integer") + .HasColumnName("teacher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("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("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Attended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("attended"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.LevelThreshold", b => + { + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("RequiredXp") + .HasColumnType("integer") + .HasColumnName("required_xp"); + + b.HasKey("Level"); + + b.HasIndex("RequiredXp") + .IsUnique(); + + b.ToTable("level_thresholds", null, t => + { + t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + + t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + b.HasData( + new + { + Level = 1, + RequiredXp = 0 + }, + new + { + Level = 2, + RequiredXp = 100 + }, + new + { + Level = 3, + RequiredXp = 300 + }, + new + { + Level = 4, + RequiredXp = 600 + }, + new + { + Level = 5, + RequiredXp = 1000 + }, + new + { + Level = 6, + RequiredXp = 1500 + }, + new + { + Level = 7, + RequiredXp = 2500 + }, + new + { + Level = 8, + RequiredXp = 4000 + }); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("address"); + + b.Property("Building") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("building"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsInformative") + .HasColumnType("boolean") + .HasColumnName("is_informative"); + + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("LlmStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("llm_status"); + + b.PrimitiveCollection("LlmTags") + .HasColumnType("text[]") + .HasColumnName("llm_tags"); + + b.Property("QualityScore") + .HasColumnType("double precision") + .HasColumnName("quality_score"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Sentiment") + .HasColumnType("integer") + .HasColumnName("sentiment"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.ReviewPromptSetting", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text") + .HasColumnName("prompt"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.ToTable("review_prompt_settings", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnrollmentYear") + .HasColumnType("integer") + .HasColumnName("enrollment_year"); + + b.Property("Faculty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("faculty"); + + b.Property("GroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("group_name"); + + b.Property("Specialty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("specialty"); + + b.Property("StudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("student_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("integer") + .HasColumnName("parent_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("Department") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("department"); + + b.Property("ModeusId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("modeus_id"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("Coins") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coins"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MicrosoftId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("microsoft_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("AwardedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("body"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("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.UserNotification", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("Notifications"); + + b.Navigation("RefreshTokens"); + + b.Navigation("Reviews"); + + b.Navigation("Roles"); + + b.Navigation("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.cs b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.cs new file mode 100644 index 0000000..656a525 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class ReviewPromptSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "review_prompt_settings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false), + prompt = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_review_prompt_settings", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "review_prompt_settings"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260522120000_ReviewLlmRawOutput.cs b/backend/UniVerse.Infrastructure/Migrations/20260522120000_ReviewLlmRawOutput.cs new file mode 100644 index 0000000..3383226 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260522120000_ReviewLlmRawOutput.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class ReviewLlmRawOutput : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "llm_raw_output", + table: "reviews", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "llm_raw_output", + table: "reviews"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.Designer.cs new file mode 100644 index 0000000..8957dcd --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.Designer.cs @@ -0,0 +1,1143 @@ +// +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("20260524173815_UniqueTeacherProfileModeusId")] + partial class UniqueTeacherProfileModeusId + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CoinReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coin_reward"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ReviewId") + .HasColumnType("integer") + .HasColumnName("review_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("IsSynced") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_synced"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("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("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("TagId") + .HasColumnType("integer") + .HasColumnName("tag_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("IsOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_open"); + + b.Property("LocationId") + .HasColumnType("integer") + .HasColumnName("location_id"); + + b.Property("MaxEnrollments") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("max_enrollments"); + + b.Property("OnlineUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("online_url"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("TeacherId") + .HasColumnType("integer") + .HasColumnName("teacher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("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("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Attended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("attended"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.LevelThreshold", b => + { + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("RequiredXp") + .HasColumnType("integer") + .HasColumnName("required_xp"); + + b.HasKey("Level"); + + b.HasIndex("RequiredXp") + .IsUnique(); + + b.ToTable("level_thresholds", null, t => + { + t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + + t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + b.HasData( + new + { + Level = 1, + RequiredXp = 0 + }, + new + { + Level = 2, + RequiredXp = 100 + }, + new + { + Level = 3, + RequiredXp = 300 + }, + new + { + Level = 4, + RequiredXp = 600 + }, + new + { + Level = 5, + RequiredXp = 1000 + }, + new + { + Level = 6, + RequiredXp = 1500 + }, + new + { + Level = 7, + RequiredXp = 2500 + }, + new + { + Level = 8, + RequiredXp = 4000 + }); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("address"); + + b.Property("Building") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("building"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsInformative") + .HasColumnType("boolean") + .HasColumnName("is_informative"); + + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("LlmRawOutput") + .HasColumnType("text") + .HasColumnName("llm_raw_output"); + + b.Property("LlmStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("llm_status"); + + b.PrimitiveCollection("LlmTags") + .HasColumnType("text[]") + .HasColumnName("llm_tags"); + + b.Property("QualityScore") + .HasColumnType("double precision") + .HasColumnName("quality_score"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Sentiment") + .HasColumnType("integer") + .HasColumnName("sentiment"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.ReviewPromptSetting", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text") + .HasColumnName("prompt"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.ToTable("review_prompt_settings", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnrollmentYear") + .HasColumnType("integer") + .HasColumnName("enrollment_year"); + + b.Property("Faculty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("faculty"); + + b.Property("GroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("group_name"); + + b.Property("Specialty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("specialty"); + + b.Property("StudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("student_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("integer") + .HasColumnName("parent_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("Department") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("department"); + + b.Property("ModeusId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("modeus_id"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ModeusId") + .IsUnique() + .HasFilter("modeus_id IS NOT NULL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("teacher_profiles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("Coins") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coins"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MicrosoftId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("microsoft_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("AwardedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_at") + .HasDefaultValueSql("NOW()"); + + b.Property("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.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("body"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("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.UserNotification", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("Notifications"); + + b.Navigation("RefreshTokens"); + + b.Navigation("Reviews"); + + b.Navigation("Roles"); + + b.Navigation("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.cs b/backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.cs new file mode 100644 index 0000000..390af8f --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class UniqueTeacherProfileModeusId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_teacher_profiles_modeus_id", + table: "teacher_profiles", + column: "modeus_id", + unique: true, + filter: "modeus_id IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_teacher_profiles_modeus_id", + table: "teacher_profiles"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs similarity index 83% rename from backend/UniVerse.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs rename to backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 45112aa..f8a7d81 100644 --- a/backend/UniVerse.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -8,7 +8,7 @@ using UniVerse.Infrastructure.Data; #nullable disable -namespace UniVerse.Infrastructure.Data.Migrations +namespace UniVerse.Infrastructure.Migrations { [DbContext(typeof(AppDbContext))] partial class AppDbContextModelSnapshot : ModelSnapshot @@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .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" }); @@ -334,6 +334,71 @@ namespace UniVerse.Infrastructure.Data.Migrations b.ToTable("lecture_enrollments", (string)null); }); + modelBuilder.Entity("UniVerse.Domain.Entities.LevelThreshold", b => + { + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("RequiredXp") + .HasColumnType("integer") + .HasColumnName("required_xp"); + + b.HasKey("Level"); + + b.HasIndex("RequiredXp") + .IsUnique(); + + b.ToTable("level_thresholds", null, t => + { + t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + + t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + b.HasData( + new + { + Level = 1, + RequiredXp = 0 + }, + new + { + Level = 2, + RequiredXp = 100 + }, + new + { + Level = 3, + RequiredXp = 300 + }, + new + { + Level = 4, + RequiredXp = 600 + }, + new + { + Level = 5, + RequiredXp = 1000 + }, + new + { + Level = 6, + RequiredXp = 1500 + }, + new + { + Level = 7, + RequiredXp = 2500 + }, + new + { + Level = 8, + RequiredXp = 4000 + }); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => { b.Property("Id") @@ -450,6 +515,10 @@ namespace UniVerse.Infrastructure.Data.Migrations .HasColumnType("integer") .HasColumnName("lecture_id"); + b.Property("LlmRawOutput") + .HasColumnType("text") + .HasColumnName("llm_raw_output"); + b.Property("LlmStatus") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -498,6 +567,34 @@ namespace UniVerse.Infrastructure.Data.Migrations b.ToTable("reviews", (string)null); }); + modelBuilder.Entity("UniVerse.Domain.Entities.ReviewPromptSetting", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text") + .HasColumnName("prompt"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.ToTable("review_prompt_settings", (string)null); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => { b.Property("Id") @@ -613,6 +710,10 @@ namespace UniVerse.Infrastructure.Data.Migrations b.HasKey("Id"); + b.HasIndex("ModeusId") + .IsUnique() + .HasFilter("modeus_id IS NOT NULL"); + b.HasIndex("UserId") .IsUnique(); @@ -667,10 +768,6 @@ namespace UniVerse.Infrastructure.Data.Migrations .HasColumnType("character varying(255)") .HasColumnName("microsoft_id"); - b.Property("Role") - .HasColumnType("integer") - .HasColumnName("role"); - b.Property("UpdatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") @@ -725,6 +822,71 @@ namespace UniVerse.Infrastructure.Data.Migrations b.ToTable("user_achievements", (string)null); }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("body"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("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") @@ -894,6 +1056,28 @@ namespace UniVerse.Infrastructure.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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"); @@ -936,10 +1120,14 @@ namespace UniVerse.Infrastructure.Data.Migrations b.Navigation("Enrollments"); + b.Navigation("Notifications"); + b.Navigation("RefreshTokens"); b.Navigation("Reviews"); + b.Navigation("Roles"); + b.Navigation("StudentProfile"); b.Navigation("TeacherProfile"); diff --git a/backend/UniVerse.Infrastructure/Notifications/EmailNotificationOptions.cs b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationOptions.cs new file mode 100644 index 0000000..378e2dd --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationOptions.cs @@ -0,0 +1,12 @@ +namespace UniVerse.Infrastructure.Notifications; + +public class EmailNotificationOptions +{ + public string Host { get; set; } = string.Empty; + public int Port { get; set; } = 587; + public bool EnableSsl { get; set; } = true; + public string? UserName { get; set; } + public string? Password { get; set; } + public string FromAddress { get; set; } = string.Empty; + public string? FromName { get; set; } +} diff --git a/backend/UniVerse.Infrastructure/Notifications/EmailNotificationProvider.cs b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationProvider.cs new file mode 100644 index 0000000..008da31 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/EmailNotificationProvider.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Mail; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Infrastructure.Notifications; + +public class EmailNotificationProvider : INotificationProvider +{ + private readonly EmailNotificationOptions _options; + private readonly ILogger _logger; + + public EmailNotificationProvider(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public string Channel => NotificationChannels.Email; + + public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default) + { + ValidateOptions(); + + using var mailMessage = new MailMessage + { + From = new MailAddress(_options.FromAddress, _options.FromName), + Subject = message.Subject, + Body = message.Body, + IsBodyHtml = false + }; + mailMessage.To.Add(new MailAddress(message.Recipient, message.RecipientName)); + + using var client = new SmtpClient(_options.Host, _options.Port) + { + EnableSsl = _options.EnableSsl + }; + + if (!string.IsNullOrWhiteSpace(_options.UserName)) + { + client.Credentials = new NetworkCredential(_options.UserName, _options.Password); + } + + _logger.LogInformation("Sending email notification to {Recipient}", message.Recipient); + await client.SendMailAsync(mailMessage, cancellationToken); + } + + private void ValidateOptions() + { + if (string.IsNullOrWhiteSpace(_options.Host)) + throw new InvalidOperationException("Email:Smtp:Host is not configured."); + if (string.IsNullOrWhiteSpace(_options.FromAddress)) + throw new InvalidOperationException("Email:Smtp:FromAddress is not configured."); + } +} diff --git a/backend/UniVerse.Infrastructure/Notifications/NotificationJob.cs b/backend/UniVerse.Infrastructure/Notifications/NotificationJob.cs new file mode 100644 index 0000000..62b735b --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/NotificationJob.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Quartz; +using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Infrastructure.Notifications; + +[DisallowConcurrentExecution] +public class NotificationJob : IJob +{ + public const string MessageDataKey = "message"; + + private readonly INotificationService _notifications; + private readonly ILogger _logger; + + public NotificationJob(INotificationService notifications, ILogger logger) + { + _notifications = notifications; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + var payload = context.MergedJobDataMap.GetString(MessageDataKey); + if (string.IsNullOrWhiteSpace(payload)) + { + _logger.LogWarning("Notification job {JobKey} does not contain message payload", context.JobDetail.Key); + return; + } + + var message = JsonSerializer.Deserialize(payload) + ?? throw new InvalidOperationException("Scheduled notification payload cannot be deserialized."); + + await _notifications.SendAsync(message, context.CancellationToken); + } +} diff --git a/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs new file mode 100644 index 0000000..7cfc674 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Entities; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Notifications; + +public class NotificationService : INotificationService +{ + private readonly AppDbContext _db; + private readonly IEnumerable _providers; + private readonly INotificationScheduler _scheduler; + private readonly ILogger _logger; + + public NotificationService( + AppDbContext db, + IEnumerable providers, + INotificationScheduler scheduler, + ILogger logger) + { + _db = db; + _providers = providers; + _scheduler = scheduler; + _logger = logger; + } + + public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message.Channel); + ArgumentException.ThrowIfNullOrWhiteSpace(message.Recipient); + ArgumentException.ThrowIfNullOrWhiteSpace(message.Subject); + ArgumentException.ThrowIfNullOrWhiteSpace(message.Body); + + var provider = _providers.FirstOrDefault(p => string.Equals(p.Channel, message.Channel, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Notification provider for channel '{message.Channel}' is not registered."); + + _logger.LogInformation("Dispatching notification through {Channel} to {Recipient}", message.Channel, message.Recipient); + await provider.SendAsync(message, cancellationToken); + } + + public Task ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default) + { + var message = new NotificationMessage( + request.Channel, + request.Recipient, + request.Subject, + request.Body, + request.RecipientName, + request.Metadata); + + return _scheduler.ScheduleAsync(message, request.SendAt, cancellationToken: cancellationToken); + } + + public async Task CreateUserNotificationAsync( + int userId, + string type, + string title, + string body, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(type); + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(body); + + var notification = new UserNotification + { + UserId = userId, + Type = type, + Title = title, + Body = body + }; + + _db.UserNotifications.Add(notification); + await _db.SaveChangesAsync(cancellationToken); + + return ToDto(notification); + } + + public async Task> GetUserNotificationsAsync( + int userId, + PaginationRequest pagination, + CancellationToken cancellationToken = default) + { + var query = _db.UserNotifications.Where(n => n.UserId == userId); + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(n => n.CreatedAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .Select(n => new UserNotificationDto( + n.Id, + n.Type, + n.Title, + n.Body, + n.IsRead, + n.CreatedAt)) + .ToListAsync(cancellationToken); + + return PagedResult.Create(items, total, pagination.Page, pagination.PageSize); + } + + public async Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default) + { + await _db.UserNotifications + .Where(n => n.UserId == userId && !n.IsRead) + .ExecuteUpdateAsync(setters => setters.SetProperty(n => n.IsRead, true), cancellationToken); + } + + private static UserNotificationDto ToDto(UserNotification notification) => new( + notification.Id, + notification.Type, + notification.Title, + notification.Body, + notification.IsRead, + notification.CreatedAt + ); +} diff --git a/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs b/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs new file mode 100644 index 0000000..1a264cb --- /dev/null +++ b/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Quartz; +using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Infrastructure.Notifications; + +public class QuartzNotificationScheduler : INotificationScheduler +{ + private const string NotificationGroup = "notifications"; + + private readonly ISchedulerFactory _schedulerFactory; + private readonly ILogger _logger; + + public QuartzNotificationScheduler(ISchedulerFactory schedulerFactory, ILogger logger) + { + _schedulerFactory = schedulerFactory; + _logger = logger; + } + + public async Task ScheduleAsync( + NotificationMessage message, + DateTimeOffset sendAt, + string? jobId = null, + CancellationToken cancellationToken = default) + { + if (sendAt <= DateTimeOffset.UtcNow) + throw new ArgumentException("Scheduled notification time must be in the future.", nameof(sendAt)); + + var scheduler = await _schedulerFactory.GetScheduler(cancellationToken); + jobId ??= Guid.NewGuid().ToString("N"); + var jobKey = new JobKey(jobId, NotificationGroup); + var payload = JsonSerializer.Serialize(message); + + var job = JobBuilder.Create() + .WithIdentity(jobKey) + .UsingJobData(NotificationJob.MessageDataKey, payload) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity($"{jobId}.trigger", NotificationGroup) + .ForJob(job) + .StartAt(sendAt) + .Build(); + + await scheduler.ScheduleJob(job, trigger, cancellationToken); + _logger.LogInformation("Scheduled notification job {JobId} for {SendAt}", jobId, sendAt); + + return new ScheduledNotificationResponse(jobId, sendAt); + } + + public async Task CancelAsync(string jobId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + + var scheduler = await _schedulerFactory.GetScheduler(cancellationToken); + var deleted = await scheduler.DeleteJob(new JobKey(jobId, NotificationGroup), cancellationToken); + if (deleted) + _logger.LogInformation("Cancelled notification job {JobId}", jobId); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/AchievementService.cs b/backend/UniVerse.Infrastructure/Services/AchievementService.cs index 12505d0..0d90b58 100644 --- a/backend/UniVerse.Infrastructure/Services/AchievementService.cs +++ b/backend/UniVerse.Infrastructure/Services/AchievementService.cs @@ -14,7 +14,7 @@ public class AchievementService : IAchievementService public AchievementService(AppDbContext db) => _db = db; public async Task> GetAllAsync() => - await _db.Achievements.OrderBy(a => a.Name).Select(a => a.ToDto()).ToListAsync(); + await _db.Achievements.OrderBy(a => a.Id).Select(a => a.ToDto()).ToListAsync(); public async Task GetByIdAsync(int id) { diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 5b684bc..679e58f 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -1,12 +1,13 @@ -using Microsoft.Identity.Client; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using UniVerse.Application.DTOs.Auth; +using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.DTOs.Users; using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; @@ -21,37 +22,33 @@ public class AuthService : IAuthService { private readonly AppDbContext _db; private readonly IConfiguration _config; + private readonly IMicrosoftAuthClient _microsoftAuth; private readonly IGamificationService _gamification; + private readonly INotificationService _notifications; + private readonly ILogger _logger; - public AuthService(AppDbContext db, IConfiguration config, IGamificationService gamification) + public AuthService( + AppDbContext db, + IConfiguration config, + IMicrosoftAuthClient microsoftAuth, + IGamificationService gamification, + INotificationService notifications, + ILogger logger) { _db = db; _config = config; + _microsoftAuth = microsoftAuth; _gamification = gamification; + _notifications = notifications; + _logger = logger; } - public async Task LoginWithMicrosoftAsync(string authorizationCode) + public async Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null) { - var tenantId = _config["AzureAd:TenantId"]; - var clientId = _config["AzureAd:ClientId"]; - var clientSecret = _config["AzureAd:ClientSecret"]; - - var app = ConfidentialClientApplicationBuilder.Create(clientId) - .WithClientSecret(clientSecret) - .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}")) - .WithRedirectUri(_config["AzureAd:RedirectUri"] ?? "http://localhost:5173/auth/callback") - .Build(); - - AuthenticationResult result; - try - { - result = await app.AcquireTokenByAuthorizationCode(new[] { "User.Read" }, authorizationCode) - .ExecuteAsync(); - } - catch (MsalException ex) - { - throw new UnauthorizedException($"Microsoft authentication failed: {ex.Message}"); - } + var effectiveRedirectUri = redirectUri + ?? _config["AzureAd:RedirectUri"] + ?? "http://localhost:5173/auth/callback"; + var result = await _microsoftAuth.ExchangeAuthorizationCodeAsync(authorizationCode, effectiveRedirectUri); // Parse claims directly from the ID token provided by Microsoft var handler = new JwtSecurityTokenHandler(); @@ -59,35 +56,61 @@ public class AuthService : IAuthService var email = idToken.Claims.FirstOrDefault(c => c.Type == "preferred_username" || c.Type == "email" || c.Type == ClaimTypes.Upn)?.Value; var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value; + var microsoftSub = idToken.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub || c.Type == "sub")?.Value; if (string.IsNullOrEmpty(email)) - throw new UnauthorizedException("Email not found in Microsoft token claims."); + throw new UnauthorizedException("Email не найден в токене Microsoft."); + if (string.IsNullOrWhiteSpace(microsoftSub)) + throw new UnauthorizedException("Sub ID не найден в токене Microsoft."); // Automatically provision user - var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + var user = await _db.Users + .Include(u => u.Roles) + .Include(u => u.TeacherProfile) + .FirstOrDefaultAsync(u => u.MicrosoftId == microsoftSub); + user ??= await _db.Users + .Include(u => u.Roles) + .Include(u => u.TeacherProfile) + .FirstOrDefaultAsync(u => u.Email == email); if (user == null) { user = new User { Email = email, DisplayName = name ?? email.Split('@')[0], - Role = UserRole.Student, // Default role + MicrosoftId = microsoftSub, IsActive = true }; _db.Users.Add(user); await _db.SaveChangesAsync(); - // Create corresponding profile + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Student }); _db.StudentProfiles.Add(new StudentProfile { UserId = user.Id }); await _db.SaveChangesAsync(); } else if (!user.IsActive) { - throw new ForbiddenException("Account is deactivated."); + throw new ForbiddenException("Аккаунт деактивирован."); + } + else + { + user.Email = email; + user.DisplayName = name ?? user.DisplayName ?? email.Split('@')[0]; + user.MicrosoftId = microsoftSub; + user.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } + + if (user.Roles.Count == 0) + { + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Student }); + await EnsureProfilesForRolesAsync(user.Id, [UserRole.Student]); + await _db.SaveChangesAsync(); } var accessToken = GenerateAccessToken(user); var refreshToken = await GenerateRefreshTokenAsync(user.Id); + await TrySendLoginNotificationAsync(user, ipAddress); return new AuthResult( new AuthResponse( @@ -99,9 +122,12 @@ public class AuthService : IAuthService ); } - public async Task DevLoginAsync(string email, string? displayName, UserRole role) + public async Task DevLoginAsync(string email, string? displayName, IReadOnlyCollection roles, string? ipAddress = null) { - var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + var normalizedRoles = (roles.Count > 0 ? roles : [UserRole.Student]).Distinct().ToList(); + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Email == email); if (user == null) { @@ -109,30 +135,29 @@ public class AuthService : IAuthService { Email = email, DisplayName = displayName ?? email.Split('@')[0], - Role = role, IsActive = true }; _db.Users.Add(user); await _db.SaveChangesAsync(); - - // Create profile based on role - if (role == UserRole.Student) - { - _db.StudentProfiles.Add(new StudentProfile { UserId = user.Id }); - await _db.SaveChangesAsync(); - } - else if (role == UserRole.Teacher) - { - _db.TeacherProfiles.Add(new TeacherProfile { UserId = user.Id }); - await _db.SaveChangesAsync(); - } } + var existing = user.Roles.Select(r => r.Role).ToHashSet(); + var toRemove = user.Roles.Where(r => !normalizedRoles.Contains(r.Role)).ToList(); + foreach (var item in toRemove) + user.Roles.Remove(item); + + foreach (var role in normalizedRoles.Where(r => !existing.Contains(r))) + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = role }); + + await EnsureProfilesForRolesAsync(user.Id, normalizedRoles); + await _db.SaveChangesAsync(); + if (!user.IsActive) - throw new ForbiddenException("Account is deactivated."); + throw new ForbiddenException("Аккаунт деактивирован."); var accessToken = GenerateAccessToken(user); var refreshToken = await GenerateRefreshTokenAsync(user.Id); + await TrySendLoginNotificationAsync(user, ipAddress); return new AuthResult( new AuthResponse( @@ -148,10 +173,18 @@ public class AuthService : IAuthService { var token = await _db.RefreshTokens .Include(rt => rt.User) + .ThenInclude(u => u.Roles) .FirstOrDefaultAsync(rt => rt.Token == refreshToken); if (token == null || !token.IsActive) - throw new ForbiddenException("Invalid or expired refresh token."); + throw new ForbiddenException("Неверный или просроченный токен обновления."); + + if (!token.User.IsActive) + { + token.RevokedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + throw new ForbiddenException("Аккаунт деактивирован."); + } // Revoke old token token.RevokedAt = DateTime.UtcNow; @@ -182,11 +215,43 @@ public class AuthService : IAuthService } } - public async Task GetCurrentUserAsync(int userId) + public async Task GetCurrentUserAsync(int userId) { - var user = await _db.Users.FindAsync(userId) + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == userId) ?? throw new NotFoundException("User", userId); - return user.ToDto(_gamification.CalculateLevel(user.Xp)); + if (!user.IsActive) + throw new ForbiddenException("Аккаунт деактивирован."); + + return user.ToCurrentUserDto(await _gamification.CalculateLevelAsync(user.Xp)); + } + + private async Task TrySendLoginNotificationAsync(User user, string? ipAddress) + { + try + { + var loginIpAddress = string.IsNullOrWhiteSpace(ipAddress) ? "unknown" : ipAddress; + var loginTime = DateTimeOffset.UtcNow; + var message = new NotificationMessage( + NotificationChannels.Email, + user.Email, + "Вход в аккаунт UniVerse", + $"Здравствуйте, {user.DisplayName ?? user.Email}!\n\nБыл выполнен вход в ваш аккаунт UniVerse в {loginTime:O} с IP-адреса: {loginIpAddress}.\n\nЕсли это были не вы, пожалуйста, немедленно свяжитесь с поддержкой.", + user.DisplayName, + new Dictionary + { + ["event"] = "account_login", + ["ip_address"] = loginIpAddress, + ["login_time_utc"] = loginTime.ToString("O") + }); + + await _notifications.SendAsync(message); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send login notification to user {UserId}", user.Id); + } } private string GenerateAccessToken(User user) @@ -195,13 +260,15 @@ public class AuthService : IAuthService Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - var claims = new[] + var claims = new List { - new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), - new Claim(JwtRegisteredClaimNames.Email, user.Email), - new Claim(ClaimTypes.Role, user.Role.ToString()), - new Claim("display_name", user.DisplayName ?? "") + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(JwtRegisteredClaimNames.Email, user.Email), + new("display_name", user.DisplayName ?? "") }; + var roles = user.Roles.Select(r => r.Role).Distinct().ToList(); + if (roles.Count == 0) roles.Add(UserRole.Student); + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role.ToString()))); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], @@ -214,6 +281,23 @@ public class AuthService : IAuthService return new JwtSecurityTokenHandler().WriteToken(token); } + private async Task EnsureProfilesForRolesAsync(int userId, IReadOnlyCollection roles) + { + if (roles.Contains(UserRole.Student)) + { + var hasStudentProfile = await _db.StudentProfiles.AnyAsync(p => p.UserId == userId); + if (!hasStudentProfile) + _db.StudentProfiles.Add(new StudentProfile { UserId = userId }); + } + + if (roles.Contains(UserRole.Teacher)) + { + var hasTeacherProfile = await _db.TeacherProfiles.AnyAsync(p => p.UserId == userId); + if (!hasTeacherProfile) + _db.TeacherProfiles.Add(new TeacherProfile { UserId = userId }); + } + } + private async Task GenerateRefreshTokenAsync(int userId) { var randomBytes = RandomNumberGenerator.GetBytes(64); diff --git a/backend/UniVerse.Infrastructure/Services/GamificationService.cs b/backend/UniVerse.Infrastructure/Services/GamificationService.cs index 077598c..3342b43 100644 --- a/backend/UniVerse.Infrastructure/Services/GamificationService.cs +++ b/backend/UniVerse.Infrastructure/Services/GamificationService.cs @@ -1,9 +1,10 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using System.Globalization; using UniVerse.Application.DTOs.Achievements; using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Gamification; +using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; using UniVerse.Domain.Entities; @@ -15,12 +16,16 @@ namespace UniVerse.Infrastructure.Services; public class GamificationService : IGamificationService { private readonly AppDbContext _db; - private readonly IConfiguration _config; + private readonly INotificationService _notifications; private readonly ILogger _logger; + private List? _levelThresholds; - public GamificationService(AppDbContext db, IConfiguration config, ILogger logger) + public GamificationService( + AppDbContext db, + INotificationService notifications, + ILogger logger) { - _db = db; _config = config; _logger = logger; + _db = db; _notifications = notifications; _logger = logger; } public async Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type, @@ -41,39 +46,183 @@ public class GamificationService : IGamificationService public async Task CheckAndAwardAchievementsAsync(int userId) { - var achievements = await _db.Achievements.ToListAsync(); + var user = await _db.Users + .Include(u => u.StudentProfile) + .FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) return; + + var achievements = await _db.Achievements.OrderBy(a => a.Id).ToListAsync(); var existing = await _db.UserAchievements.Where(ua => ua.UserId == userId) .Select(ua => ua.AchievementId).ToListAsync(); var reviews = await _db.Reviews.CountAsync(r => r.UserId == userId); + var registered = await _db.LectureEnrollments.CountAsync(e => e.UserId == userId); var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == userId && e.Attended); + var activeRegistrations = await _db.LectureEnrollments + .Include(e => e.Lecture) + .CountAsync(e => e.UserId == userId && e.Lecture.StartsAt > DateTime.UtcNow); + var earnedCoins = await _db.CoinTransactions + .Where(ct => ct.UserId == userId && ct.Amount > 0) + .SumAsync(ct => (int?)ct.Amount) ?? 0; + var attendanceStreakWeeks = await CalculateAttendanceStreakWeeksAsync(userId); + var profileCompleted = !string.IsNullOrWhiteSpace(user.DisplayName) + && !string.IsNullOrWhiteSpace(user.AvatarUrl); + var firstActivity = registered > 0 || reviews > 0 || attended > 0; foreach (var achievement in achievements.Where(a => !existing.Contains(a.Id))) { - var earned = achievement.Condition switch + if (!TryParseCondition(achievement.Condition, out var type, out var value)) continue; + + var earned = type switch { - "reviews_1" => reviews >= 1, - "reviews_5" => reviews >= 5, - "reviews_10" => reviews >= 10, - "attended_5" => attended >= 5, - "attended_10" => attended >= 10, + "lectures_attended" => attended >= value, + "reviews_written" => reviews >= value, + "lectures_registered" => registered >= value, + "active_registrations" => activeRegistrations >= value, + "attendance_streak_weeks" => attendanceStreakWeeks >= value, + "attended_registered" => attended >= value, + "coins_earned" => earnedCoins >= value, + "level_reached" => await CalculateLevelAsync(user.Xp) >= value, + "profile_completed" => profileCompleted && value <= 1, + "first_activity" => firstActivity && value <= 1, _ => false }; if (!earned) continue; + _db.UserAchievements.Add(new UserAchievement { UserId = userId, AchievementId = achievement.Id }); if (achievement.CoinReward > 0) + { await AwardCoinsAsync(userId, achievement.CoinReward, CoinTransactionType.AchievementReward, achievementId: achievement.Id, description: $"Achievement: {achievement.Name}"); + earnedCoins += achievement.CoinReward; + } + + await TryNotifyAchievementAsync(user, achievement); } await _db.SaveChangesAsync(); } - public int CalculateLevel(int xp) + private async Task TryNotifyAchievementAsync(User user, Achievement achievement) { - var thresholds = _config.GetSection("Gamification:XpThresholds").Get() - ?? [0, 100, 300, 600, 1000, 1500, 2500, 4000]; - for (int i = thresholds.Length - 1; i >= 0; i--) - if (xp >= thresholds[i]) return i + 1; - return 1; + try + { + var title = $"Новое достижение: {achievement.Name}"; + var rewardText = achievement.CoinReward > 0 + ? $" Награда: {achievement.CoinReward} монет." + : string.Empty; + var body = $"{achievement.Description ?? "Вы выполнили условие достижения."}{rewardText}"; + + await _notifications.CreateUserNotificationAsync(user.Id, "achievement", title, body); + + await _notifications.SendAsync(new NotificationMessage( + NotificationChannels.Email, + user.Email, + title, + $"Здравствуйте, {user.DisplayName ?? user.Email}!\n\nПоздравляем: вы получили достижение «{achievement.Name}» в UniVerse.\n\n{body}", + user.DisplayName, + new Dictionary + { + ["event"] = "achievement_earned", + ["achievement_id"] = achievement.Id.ToString(), + ["achievement_name"] = achievement.Name + })); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send achievement notification {AchievementId} to user {UserId}", achievement.Id, user.Id); + } + } + + private static bool TryParseCondition(string? condition, out string type, out int value) + { + type = string.Empty; + value = 0; + + var parts = condition?.Split(':', 2, StringSplitOptions.TrimEntries); + if (parts is not { Length: 2 } || string.IsNullOrWhiteSpace(parts[0])) return false; + if (!int.TryParse(parts[1], out value)) return false; + + type = parts[0]; + return value >= 0; + } + + private async Task CalculateAttendanceStreakWeeksAsync(int userId) + { + var lectureDates = await _db.LectureEnrollments + .Include(e => e.Lecture) + .Where(e => e.UserId == userId && e.Attended) + .Select(e => e.Lecture.StartsAt) + .ToListAsync(); + + var weekStarts = lectureDates + .Select(GetIsoWeekStart) + .Distinct() + .OrderBy(d => d) + .ToList(); + + var best = 0; + var current = 0; + DateOnly? previous = null; + + foreach (var weekStart in weekStarts) + { + current = previous.HasValue && weekStart.DayNumber - previous.Value.DayNumber == 7 + ? current + 1 + : 1; + best = Math.Max(best, current); + previous = weekStart; + } + + return best; + } + + private static DateOnly GetIsoWeekStart(DateTime date) + { + var isoYear = ISOWeek.GetYear(date); + var isoWeek = ISOWeek.GetWeekOfYear(date); + return DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday)); + } + + public async Task CalculateLevelAsync(int xp) + { + var thresholds = await GetLevelThresholdsAsync(); + return thresholds + .Where(t => xp >= t.RequiredXp) + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .LastOrDefault()?.Level ?? thresholds[0].Level; + } + + public async Task GetLevelProgressAsync(int xp) + { + var thresholds = await GetLevelThresholdsAsync(); + var current = thresholds + .Where(t => xp >= t.RequiredXp) + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .LastOrDefault() ?? thresholds[0]; + var next = thresholds + .Where(t => t.RequiredXp > current.RequiredXp) + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .FirstOrDefault(); + + return new LevelProgressDto(current.RequiredXp, next?.RequiredXp); + } + + private async Task> GetLevelThresholdsAsync() + { + if (_levelThresholds is { Count: > 0 }) return _levelThresholds; + + _levelThresholds = await _db.LevelThresholds + .AsNoTracking() + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .ToListAsync(); + + if (_levelThresholds.Count == 0) + _levelThresholds.Add(new LevelThreshold { Level = 1, RequiredXp = 0 }); + + return _levelThresholds; } public async Task> GetUserAchievementsAsync(int userId) => diff --git a/backend/UniVerse.Infrastructure/Services/LectureService.cs b/backend/UniVerse.Infrastructure/Services/LectureService.cs index b28ffdc..aa49044 100644 --- a/backend/UniVerse.Infrastructure/Services/LectureService.cs +++ b/backend/UniVerse.Infrastructure/Services/LectureService.cs @@ -1,10 +1,12 @@ using Microsoft.EntityFrameworkCore; using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Lectures; +using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; using UniVerse.Domain.Entities; using UniVerse.Domain.Exceptions; +using UniVerse.Domain.Services; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; @@ -12,13 +14,24 @@ namespace UniVerse.Infrastructure.Services; public class LectureService : ILectureService { private readonly AppDbContext _db; - public LectureService(AppDbContext db) => _db = db; + private readonly IGamificationService _gamification; + private readonly INotificationScheduler _notificationScheduler; + + public LectureService( + AppDbContext db, + IGamificationService gamification, + INotificationScheduler notificationScheduler) + { + _db = db; + _gamification = gamification; + _notificationScheduler = notificationScheduler; + } private IQueryable BaseQuery() => _db.Lectures .Include(l => l.Course).Include(l => l.Teacher) .Include(l => l.Location).Include(l => l.Enrollments); - public async Task> GetAllAsync(LectureFilterRequest filter) + public async Task> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null) { var query = BaseQuery(); if (filter.CourseId.HasValue) query = query.Where(l => l.CourseId == filter.CourseId); @@ -37,7 +50,11 @@ public class LectureService : ILectureService var total = await query.CountAsync(); var items = await query.OrderBy(l => l.StartsAt) .Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync(); - return PagedResult.Create(items.Select(l => l.ToDto()).ToList(), total, filter.Page, filter.PageSize); + return PagedResult.Create( + items.Select(l => l.ToDto(currentUserId.HasValue && l.Enrollments.Any(e => e.UserId == currentUserId.Value))).ToList(), + total, + filter.Page, + filter.PageSize); } public async Task GetByIdAsync(int id, int? currentUserId = null) @@ -65,37 +82,63 @@ public class LectureService : ILectureService return full.ToDto(); } - public async Task UpdateAsync(int id, UpdateLectureRequest req) + public async Task UpdateAsync(int id, UpdateLectureRequest req, int currentUserId, bool isAdmin = false) { - var lecture = await _db.Lectures.FindAsync(id) ?? throw new NotFoundException("Lecture", id); + var lecture = await _db.Lectures + .Include(l => l.Location) + .Include(l => l.Enrollments) + .ThenInclude(e => e.User) + .FirstOrDefaultAsync(l => l.Id == id) ?? throw new NotFoundException("Lecture", id); + EnsureTeacherOwnsLecture(lecture, currentUserId, isAdmin); lecture.TeacherId = req.TeacherId; lecture.LocationId = req.LocationId; lecture.Title = req.Title; lecture.Description = req.Description; lecture.Format = req.Format; lecture.StartsAt = req.StartsAt; lecture.EndsAt = req.EndsAt; lecture.IsOpen = req.IsOpen; lecture.MaxEnrollments = req.MaxEnrollments; lecture.OnlineUrl = req.OnlineUrl; lecture.UpdatedAt = DateTime.UtcNow; + lecture.Location = req.LocationId.HasValue + ? await _db.Locations.FindAsync(req.LocationId.Value) + : null; await _db.SaveChangesAsync(); + await RescheduleLectureRemindersAsync(lecture); var full = await BaseQuery().FirstAsync(l => l.Id == id); return full.ToDto(); } public async Task DeleteAsync(int id) { - var lecture = await _db.Lectures.FindAsync(id) ?? throw new NotFoundException("Lecture", id); + var lecture = await _db.Lectures + .Include(l => l.Enrollments) + .FirstOrDefaultAsync(l => l.Id == id) ?? throw new NotFoundException("Lecture", id); + await CancelLectureRemindersAsync(lecture); _db.Lectures.Remove(lecture); await _db.SaveChangesAsync(); } public async Task EnrollAsync(int lectureId, int userId) { - var lecture = await _db.Lectures.Include(l => l.Enrollments) + var lecture = await _db.Lectures + .Include(l => l.Location) + .Include(l => l.Enrollments) .FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId); + var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId); if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment."); if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments) throw new ConflictException("Lecture is full."); if (lecture.Enrollments.Any(e => e.UserId == userId)) throw new ConflictException("Already enrolled."); + + var level = await _gamification.CalculateLevelAsync(user.Xp); + var enrollmentLimit = EnrollmentSlotPolicy.GetLimitForLevel(level); + var activeEnrollments = await _db.LectureEnrollments + .CountAsync(e => e.UserId == userId && !e.Attended); + + if (activeEnrollments >= enrollmentLimit) + throw new ConflictException("Enrollment limit reached for your level."); + _db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId }); await _db.SaveChangesAsync(); + await ScheduleLectureRemindersAsync(lecture, user); + await _gamification.CheckAndAwardAchievementsAsync(userId); } public async Task UnenrollAsync(int lectureId, int userId) @@ -103,21 +146,26 @@ public class LectureService : ILectureService var enrollment = await _db.LectureEnrollments .FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId) ?? throw new NotFoundException("Enrollment not found."); + await CancelLectureRemindersAsync(lectureId, userId); _db.LectureEnrollments.Remove(enrollment); await _db.SaveChangesAsync(); } - public async Task MarkAttendanceAsync(int lectureId, int userId, bool attended) + public async Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false) { + await EnsureTeacherOwnsLectureAsync(lectureId, currentUserId, isAdmin); var enrollment = await _db.LectureEnrollments .FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId) ?? throw new NotFoundException("Enrollment not found."); enrollment.Attended = attended; await _db.SaveChangesAsync(); + if (attended) + await _gamification.CheckAndAwardAchievementsAsync(userId); } - public async Task> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination) + public async Task> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false) { + await EnsureTeacherOwnsLectureAsync(lectureId, currentUserId, isAdmin); var query = _db.LectureEnrollments.Include(e => e.User) .Where(e => e.LectureId == lectureId); var total = await query.CountAsync(); @@ -125,4 +173,137 @@ public class LectureService : ILectureService .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); return PagedResult.Create(items.Select(e => e.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); } + + private async Task EnsureTeacherOwnsLectureAsync(int lectureId, int currentUserId, bool isAdmin) + { + if (isAdmin) + return; + + var lecture = await _db.Lectures.FirstOrDefaultAsync(l => l.Id == lectureId) + ?? throw new NotFoundException("Lecture", lectureId); + EnsureTeacherOwnsLecture(lecture, currentUserId, isAdmin: false); + } + + private static void EnsureTeacherOwnsLecture(Lecture lecture, int currentUserId, bool isAdmin) + { + if (!isAdmin && lecture.TeacherId != currentUserId) + throw new ForbiddenException("Teacher can access only their own lectures."); + } + + private async Task RescheduleLectureRemindersAsync(Lecture lecture) + { + foreach (var enrollment in lecture.Enrollments) + { + await ScheduleLectureRemindersAsync(lecture, enrollment.User); + } + } + + private async Task ScheduleLectureRemindersAsync(Lecture lecture, User user) + { + if (string.IsNullOrWhiteSpace(user.Email)) + { + await CancelLectureRemindersAsync(lecture.Id, user.Id); + return; + } + + foreach (var reminder in GetLectureReminders(lecture)) + { + var jobId = GetReminderJobId(lecture.Id, user.Id, reminder.Kind); + await _notificationScheduler.CancelAsync(jobId); + + if (reminder.SendAt <= DateTimeOffset.UtcNow) + continue; + + var message = new NotificationMessage( + NotificationChannels.Email, + user.Email, + reminder.Subject, + reminder.Body, + user.DisplayName, + new Dictionary + { + ["lectureId"] = lecture.Id.ToString(), + ["reminderType"] = reminder.Kind + }); + + await _notificationScheduler.ScheduleAsync(message, reminder.SendAt, jobId); + } + } + + private async Task CancelLectureRemindersAsync(Lecture lecture) + { + foreach (var enrollment in lecture.Enrollments) + { + await CancelLectureRemindersAsync(lecture.Id, enrollment.UserId); + } + } + + private async Task CancelLectureRemindersAsync(int lectureId, int userId) + { + foreach (var kind in new[] { "starts-in-3-hours", "starts-in-1-hour", "ended" }) + { + await _notificationScheduler.CancelAsync(GetReminderJobId(lectureId, userId, kind)); + } + } + + private static IEnumerable GetLectureReminders(Lecture lecture) + { + var startsAt = ToUtcOffset(lecture.StartsAt); + var endsAt = ToUtcOffset(lecture.EndsAt); + var place = !string.IsNullOrWhiteSpace(lecture.OnlineUrl) + ? $"Ссылка: {lecture.OnlineUrl}" + : lecture.Location is null + ? null + : $"Место проведения: {lecture.Location.Name}"; + + yield return new LectureReminder( + "starts-in-3-hours", + startsAt.AddHours(-3), + $"Лекция \"{lecture.Title}\" начнется через 3 часа", + BuildStartsSoonBody(lecture, startsAt, place, "3 часа")); + + yield return new LectureReminder( + "starts-in-1-hour", + startsAt.AddHours(-1), + $"Лекция \"{lecture.Title}\" начнется через 1 час", + BuildStartsSoonBody(lecture, startsAt, place, "1 час")); + + yield return new LectureReminder( + "ended", + endsAt, + $"Оцените лекцию \"{lecture.Title}\"", + BuildLectureEndedBody(lecture)); + } + + private static string BuildStartsSoonBody(Lecture lecture, DateTimeOffset startsAt, string? place, string interval) + { + var lines = new List + { + $"Напоминаем: лекция \"{lecture.Title}\" начнется через {interval}.", + $"Начало: {FormatDateTime(startsAt)}." + }; + + if (!string.IsNullOrWhiteSpace(place)) + lines.Add(place); + + return string.Join(Environment.NewLine, lines); + } + + private static string BuildLectureEndedBody(Lecture lecture) => + $"Лекция \"{lecture.Title}\" завершилась. Пожалуйста, оставьте оценку и отзыв в UniVerse: это поможет преподавателю и другим студентам."; + + private static string GetReminderJobId(int lectureId, int userId, string kind) => + $"lecture-{lectureId}-user-{userId}-{kind}"; + + private static DateTimeOffset ToUtcOffset(DateTime value) + { + var utc = value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value, DateTimeKind.Utc); + return new DateTimeOffset(utc); + } + + private static string FormatDateTime(DateTimeOffset value) => value.ToString("dd.MM.yyyy HH:mm 'UTC'"); + + private sealed record LectureReminder(string Kind, DateTimeOffset SendAt, string Subject, string Body); } diff --git a/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs b/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs index 898aeae..f2eb71f 100644 --- a/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs +++ b/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs @@ -31,10 +31,10 @@ public class LlmAnalysisService : ILlmAnalysisService var result = await _llm.AnalyzeReviewAsync(review.Text ?? "", context); review.QualityScore = result.QualityScore; - review.Sentiment = Enum.TryParse(result.Sentiment, true, out var s) - ? s : ReviewSentiment.Neutral; + review.Sentiment = ParseSentiment(result.Sentiment); review.LlmTags = result.Tags; review.IsInformative = result.IsInformative; + review.LlmRawOutput = result.RawOutput; review.LlmStatus = ReviewLlmStatus.Analyzed; review.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); @@ -53,14 +53,16 @@ public class LlmAnalysisService : ILlmAnalysisService } } - public async Task ProcessPendingReviewsAsync() + private static ReviewSentiment ParseSentiment(string value) { - var pending = await _db.Reviews - .Where(r => r.LlmStatus == ReviewLlmStatus.Pending) - .OrderBy(r => r.CreatedAt).Take(10) - .Select(r => r.Id).ToListAsync(); - - foreach (var id in pending) - await AnalyzeReviewAsync(id); + var normalized = value.Trim().ToLowerInvariant(); + return normalized switch + { + "positive" or "положительный" or "положительная" or "позитивный" or "позитивная" => ReviewSentiment.Positive, + "negative" or "отрицательный" or "отрицательная" or "негативный" or "негативная" => ReviewSentiment.Negative, + "neutral" or "нейтральный" or "нейтральная" => ReviewSentiment.Neutral, + _ when Enum.TryParse(value, true, out var sentiment) => sentiment, + _ => ReviewSentiment.Neutral + }; } } diff --git a/backend/UniVerse.Infrastructure/Services/ReviewPromptService.cs b/backend/UniVerse.Infrastructure/Services/ReviewPromptService.cs new file mode 100644 index 0000000..a7165c2 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/ReviewPromptService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Reviews; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Prompts; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class ReviewPromptService : IReviewPromptService +{ + private readonly AppDbContext _db; + + public ReviewPromptService(AppDbContext db) + { + _db = db; + } + + public async Task GetAsync() + { + var setting = await _db.ReviewPromptSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == ReviewPromptSetting.SingletonId); + + return setting is null + ? new ReviewPromptDto(ReviewPromptTemplate.Default, null) + : new ReviewPromptDto(setting.Prompt, setting.UpdatedAt); + } + + public async Task UpdateAsync(UpdateReviewPromptRequest request) + { + ValidatePrompt(request.Prompt); + + var now = DateTime.UtcNow; + var setting = await _db.ReviewPromptSettings + .FirstOrDefaultAsync(s => s.Id == ReviewPromptSetting.SingletonId); + + if (setting is null) + { + setting = new ReviewPromptSetting + { + Id = ReviewPromptSetting.SingletonId, + Prompt = request.Prompt, + CreatedAt = now, + UpdatedAt = now + }; + _db.ReviewPromptSettings.Add(setting); + } + else + { + setting.Prompt = request.Prompt; + setting.UpdatedAt = now; + } + + await _db.SaveChangesAsync(); + + return new ReviewPromptDto(setting.Prompt, setting.UpdatedAt); + } + + private static void ValidatePrompt(string prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new BadRequestException("Prompt must not be empty."); + + if (!ReviewPromptTemplate.HasRequiredPlaceholders(prompt)) + throw new BadRequestException( + $"Prompt must contain {ReviewPromptTemplate.LectureContextPlaceholder} and {ReviewPromptTemplate.ReviewTextPlaceholder} placeholders."); + } +} diff --git a/backend/UniVerse.Infrastructure/Services/ReviewService.cs b/backend/UniVerse.Infrastructure/Services/ReviewService.cs index b70aaba..eecc608 100644 --- a/backend/UniVerse.Infrastructure/Services/ReviewService.cs +++ b/backend/UniVerse.Infrastructure/Services/ReviewService.cs @@ -13,7 +13,18 @@ namespace UniVerse.Infrastructure.Services; public class ReviewService : IReviewService { private readonly AppDbContext _db; - public ReviewService(AppDbContext db) => _db = db; + private readonly IGamificationService _gamification; + private readonly IReviewAnalysisQueue _reviewAnalysisQueue; + + public ReviewService( + AppDbContext db, + IGamificationService gamification, + IReviewAnalysisQueue reviewAnalysisQueue) + { + _db = db; + _gamification = gamification; + _reviewAnalysisQueue = reviewAnalysisQueue; + } private IQueryable BaseQuery() => _db.Reviews .Include(r => r.Lecture).Include(r => r.User); @@ -31,6 +42,8 @@ public class ReviewService : IReviewService }; _db.Reviews.Add(review); await _db.SaveChangesAsync(); + await _gamification.CheckAndAwardAchievementsAsync(userId); + await _reviewAnalysisQueue.EnqueueAsync(review.Id); var full = await BaseQuery().FirstAsync(r => r.Id == review.Id); return full.ToDto(); } @@ -47,9 +60,10 @@ public class ReviewService : IReviewService var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id); if (review.UserId != userId) throw new ForbiddenException(); review.Rating = req.Rating; review.Text = req.Text; - review.LlmStatus = ReviewLlmStatus.Pending; + ResetLlmAnalysis(review); review.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); + await _reviewAnalysisQueue.EnqueueAsync(review.Id); return await GetByIdAsync(id); } @@ -61,8 +75,23 @@ public class ReviewService : IReviewService await _db.SaveChangesAsync(); } - public async Task> GetByLectureAsync(int lectureId, PaginationRequest pagination) + public async Task> GetByLectureAsync( + int lectureId, + PaginationRequest pagination, + int? currentUserId = null, + bool isAdmin = false) { + if (!isAdmin) + { + if (!currentUserId.HasValue) + throw new ForbiddenException(); + + var lecture = await _db.Lectures.FirstOrDefaultAsync(l => l.Id == lectureId) + ?? throw new NotFoundException("Lecture", lectureId); + if (lecture.TeacherId != currentUserId.Value) + throw new ForbiddenException("Teacher can access reviews only for their own lectures."); + } + var query = BaseQuery().Where(r => r.LectureId == lectureId); var total = await query.CountAsync(); var items = await query.OrderByDescending(r => r.CreatedAt) @@ -79,20 +108,34 @@ public class ReviewService : IReviewService return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); } - public async Task> GetPendingAsync(PaginationRequest pagination) + public async Task> GetAllAsync(ReviewFilterRequest filter) { - var query = BaseQuery().Where(r => r.LlmStatus == ReviewLlmStatus.Pending); + var query = BaseQuery(); + if (filter.LlmStatus.HasValue) + query = query.Where(r => r.LlmStatus == filter.LlmStatus.Value); + var total = await query.CountAsync(); - var items = await query.OrderBy(r => r.CreatedAt) - .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); - return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); + var items = await query.OrderByDescending(r => r.CreatedAt) + .Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync(); + return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, filter.Page, filter.PageSize); } public async Task ReanalyzeAsync(int id) { var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id); - review.LlmStatus = ReviewLlmStatus.Pending; + ResetLlmAnalysis(review); review.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); + await _reviewAnalysisQueue.EnqueueAsync(review.Id); + } + + private static void ResetLlmAnalysis(Review review) + { + review.LlmStatus = ReviewLlmStatus.Pending; + review.Sentiment = null; + review.QualityScore = null; + review.IsInformative = null; + review.LlmTags = null; + review.LlmRawOutput = null; } } diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index 7b3556e..f5fb967 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -1,8 +1,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using System.Text.Json; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; @@ -21,19 +23,76 @@ public class ScheduleSyncService : IScheduleSyncService public async Task SyncScheduleAsync(SyncScheduleRequest request) { + const string stage = "schedule"; int created = 0, updated = 0, skipped = 0; try { var events = await _modeus.SearchEventsAsync(request); - foreach (var ev in events.Events) + var embeddedRoomCapacityById = BuildRoomCapacityLookup(events.Embedded?.Rooms); + IReadOnlyDictionary? syncedRoomCapacityById = null; + + foreach (var ev in events.EventItems) { + if (string.IsNullOrWhiteSpace(ev.Id) || string.IsNullOrWhiteSpace(ev.Name)) + { + skipped++; + continue; + } + + var courseUnitId = GetHrefId(ev.Links?.CourseUnitRealization?.Href); + var courseUnit = events.Embedded?.CourseUnitRealizations? + .FirstOrDefault(c => c.Id == courseUnitId); + var courseExternalId = courseUnit?.Id ?? ev.TypeId ?? ev.Id; + var courseName = courseUnit?.Name ?? ev.Name; + var location = await UpsertEventLocationAsync(events, ev.Id); + var teacher = await UpsertEventTeacherAsync(events, ev.Id); + var roomId = GetEventRoomId(events, ev.Id); + var maxEnrollments = GetRoomCapacity(embeddedRoomCapacityById, roomId); + if (maxEnrollments is null && !string.IsNullOrWhiteSpace(roomId)) + { + syncedRoomCapacityById ??= await LoadRoomCapacityLookupAsync(); + maxEnrollments = GetRoomCapacity(syncedRoomCapacityById, roomId); + } + + var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0; + var startsAt = EnsureUtc(ev.StartsAt); + var endsAt = EnsureUtc(ev.EndsAt); + var existing = await _db.Lectures.FirstOrDefaultAsync(l => l.ExternalId == ev.Id); - if (existing != null) { updated++; existing.StartsAt = ev.StartsAt; existing.EndsAt = ev.EndsAt; existing.UpdatedAt = DateTime.UtcNow; } + if (existing != null) + { + existing.Title = ev.Name; + existing.Description = ev.Description; + existing.StartsAt = startsAt; + existing.EndsAt = endsAt; + existing.LocationId = location?.Id; + existing.TeacherId = teacher?.Id; + existing.MaxEnrollments = lectureCapacity; + existing.UpdatedAt = DateTime.UtcNow; + updated++; + } else { - var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == ev.TypeId); - if (course == null) { course = new Course { Name = ev.Name, ExternalId = ev.TypeId, IsSynced = true }; _db.Courses.Add(course); await _db.SaveChangesAsync(); } - _db.Lectures.Add(new Lecture { CourseId = course.Id, Title = ev.Name, ExternalId = ev.Id, StartsAt = ev.StartsAt, EndsAt = ev.EndsAt }); + var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == courseExternalId); + if (course == null) + { + course = new Course { Name = courseName, ExternalId = courseExternalId, IsSynced = true }; + _db.Courses.Add(course); + await _db.SaveChangesAsync(); + } + + _db.Lectures.Add(new Lecture + { + CourseId = course.Id, + TeacherId = teacher?.Id, + LocationId = location?.Id, + Title = ev.Name, + Description = ev.Description, + ExternalId = ev.Id, + StartsAt = startsAt, + EndsAt = endsAt, + MaxEnrollments = lectureCapacity + }); created++; } } @@ -45,7 +104,17 @@ public class ScheduleSyncService : IScheduleSyncService catch (Exception ex) { _logger.LogError(ex, "Schedule sync failed"); - var result = new SyncResultDto(created, updated, skipped, ex.Message); + var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( + ex, + stage, + created, + updated, + skipped, + [ + $"requestJson={BuildScheduleRequestJson(request)}", + $"timeMin={request.TimeMin:O}", + $"timeMax={request.TimeMax:O}" + ])); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); return result; } @@ -53,16 +122,60 @@ public class ScheduleSyncService : IScheduleSyncService public async Task SyncRoomsAsync() { - int created = 0, updated = 0; - var rooms = await _modeus.SearchRoomsAsync(); - foreach (var room in rooms.Rooms) + const string stage = "rooms"; + int created = 0, updated = 0, skipped = 0; + try { - var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); - if (existing != null) { existing.Name = room.Name; existing.Building = room.Building; updated++; } - else { _db.Locations.Add(new Location { Name = room.Name, Building = room.Building, ExternalId = room.Id }); created++; } + var rooms = await _modeus.SearchRoomsAsync(); + foreach (var room in rooms?.RoomItems ?? []) + { + if (room is null || string.IsNullOrWhiteSpace(room.Id) || string.IsNullOrWhiteSpace(room.Name)) + { + skipped++; + continue; + } + + var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); + if (existing != null) + { + existing.Name = room.Name; + existing.Room = room.NameShort; + existing.Building = room.Building?.Name ?? room.Building?.NameShort; + existing.Address = room.Building?.Address; + updated++; + } + else + { + _db.Locations.Add(new Location + { + Name = room.Name, + Room = room.NameShort, + Building = room.Building?.Name ?? room.Building?.NameShort, + Address = room.Building?.Address, + ExternalId = room.Id + }); + created++; + } + } + + await _db.SaveChangesAsync(); + var result = new SyncResultDto(created, updated, skipped, null); + _lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Rooms sync failed"); + var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( + ex, + stage, + created, + updated, + skipped, + ["request=name:, sort:+building.name,+name, deleted:false, page size:100"])); + _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); + return result; } - await _db.SaveChangesAsync(); - return new SyncResultDto(created, updated, 0, null); } public async Task> SearchEmployeesAsync(string fullname) @@ -72,4 +185,356 @@ public class ScheduleSyncService : IScheduleSyncService } public Task GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); + + private async Task UpsertEventTeacherAsync(ModeusEventsResponse events, string eventId) + { + var personId = events.Embedded?.EventAttendees? + .Where(attendee => string.Equals(attendee.RoleId, "TEACH", StringComparison.OrdinalIgnoreCase)) + .Select(attendee => new + { + EventId = GetHrefId(attendee.Links?.Event?.Href), + PersonId = GetHrefId(attendee.Links?.Person?.Href) + }) + .FirstOrDefault(link => link.EventId == eventId) + ?.PersonId; + + if (string.IsNullOrWhiteSpace(personId)) + return null; + + var person = events.Embedded?.Persons?.FirstOrDefault(item => item.Id == personId); + var fullName = BuildPersonFullName(person); + if (string.IsNullOrWhiteSpace(fullName)) + return null; + + var existingProfile = await _db.TeacherProfiles + .Include(profile => profile.User) + .ThenInclude(user => user.Roles) + .FirstOrDefaultAsync(profile => profile.ModeusId == personId); + var subId = existingProfile?.User.MicrosoftId; + if (string.IsNullOrWhiteSpace(subId)) + subId = await TryGetTeacherSubIdAsync(fullName); + + User? ssoUser = null; + if (!string.IsNullOrWhiteSpace(subId)) + { + ssoUser = await _db.Users + .Include(item => item.Roles) + .Include(item => item.TeacherProfile) + .FirstOrDefaultAsync(item => item.MicrosoftId == subId); + } + + if (existingProfile != null && ssoUser != null && existingProfile.UserId != ssoUser.Id) + return await MergeTeacherPlaceholderAsync(existingProfile, ssoUser, fullName, subId); + + if (existingProfile != null) + { + existingProfile.User.DisplayName = fullName; + if (!string.IsNullOrWhiteSpace(subId)) + existingProfile.User.MicrosoftId = subId; + existingProfile.User.UpdatedAt = DateTime.UtcNow; + EnsureTeacherRole(existingProfile.User); + return existingProfile.User; + } + + if (ssoUser != null) + { + ssoUser.DisplayName = fullName; + ssoUser.UpdatedAt = DateTime.UtcNow; + EnsureTeacherRole(ssoUser); + EnsureTeacherProfile(ssoUser, personId); + await _db.SaveChangesAsync(); + return ssoUser; + } + + var email = BuildModeusTeacherEmail(personId); + var user = await _db.Users + .Include(item => item.Roles) + .Include(item => item.TeacherProfile) + .FirstOrDefaultAsync(item => item.Email == email); + + if (user == null) + { + user = new User + { + Email = email, + DisplayName = fullName, + MicrosoftId = subId, + IsActive = true, + TeacherProfile = new TeacherProfile { ModeusId = personId } + }; + user.Roles.Add(new UserRoleAssignment { User = user, Role = UserRole.Teacher }); + _db.Users.Add(user); + await _db.SaveChangesAsync(); + return user; + } + + user.DisplayName = fullName; + if (!string.IsNullOrWhiteSpace(subId)) + user.MicrosoftId = subId; + user.UpdatedAt = DateTime.UtcNow; + if (user.TeacherProfile == null) + user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = personId }; + else + user.TeacherProfile.ModeusId = personId; + + EnsureTeacherRole(user); + + await _db.SaveChangesAsync(); + return user; + } + + private async Task TryGetTeacherSubIdAsync(string fullName) + { + try + { + return await _modeus.GetSubIdByFullNameAsync(fullName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not resolve SSO sub id for teacher {TeacherFullName}. A placeholder teacher will be used until a future sync succeeds.", fullName); + return null; + } + } + + private async Task MergeTeacherPlaceholderAsync( + TeacherProfile placeholderProfile, + User targetUser, + string fullName, + string? subId) + { + var placeholderUser = placeholderProfile.User; + + var lectures = await _db.Lectures + .Where(lecture => lecture.TeacherId == placeholderUser.Id) + .ToListAsync(); + foreach (var lecture in lectures) + lecture.TeacherId = targetUser.Id; + + targetUser.DisplayName = fullName; + if (!string.IsNullOrWhiteSpace(subId)) + targetUser.MicrosoftId = subId; + targetUser.UpdatedAt = DateTime.UtcNow; + EnsureTeacherRole(targetUser); + + if (targetUser.TeacherProfile == null) + { + placeholderProfile.UserId = targetUser.Id; + placeholderProfile.User = targetUser; + targetUser.TeacherProfile = placeholderProfile; + placeholderUser.TeacherProfile = null; + } + else + { + targetUser.TeacherProfile.ModeusId = placeholderProfile.ModeusId; + _db.TeacherProfiles.Remove(placeholderProfile); + } + + if (await CanDeletePlaceholderUserAsync(placeholderUser.Id)) + _db.Users.Remove(placeholderUser); + + await _db.SaveChangesAsync(); + return targetUser; + } + + private async Task CanDeletePlaceholderUserAsync(int userId) => + !await _db.StudentProfiles.AnyAsync(profile => profile.UserId == userId) + && !await _db.RefreshTokens.AnyAsync(token => token.UserId == userId) + && !await _db.LectureEnrollments.AnyAsync(enrollment => enrollment.UserId == userId) + && !await _db.Reviews.AnyAsync(review => review.UserId == userId) + && !await _db.UserAchievements.AnyAsync(achievement => achievement.UserId == userId) + && !await _db.CoinTransactions.AnyAsync(transaction => transaction.UserId == userId) + && !await _db.UserNotifications.AnyAsync(notification => notification.UserId == userId); + + private static void EnsureTeacherProfile(User user, string modeusId) + { + if (user.TeacherProfile == null) + user.TeacherProfile = new TeacherProfile { UserId = user.Id, ModeusId = modeusId }; + else + user.TeacherProfile.ModeusId = modeusId; + } + + private static void EnsureTeacherRole(User user) + { + if (!user.Roles.Any(role => role.Role == UserRole.Teacher)) + user.Roles.Add(new UserRoleAssignment { UserId = user.Id, Role = UserRole.Teacher }); + } + + private async Task UpsertEventLocationAsync(ModeusEventsResponse events, string eventId) + { + var roomId = GetEventRoomId(events, eventId); + + if (string.IsNullOrWhiteSpace(roomId)) + return null; + + var room = events.Embedded?.Rooms?.FirstOrDefault(item => item.Id == roomId); + if (room == null || string.IsNullOrWhiteSpace(room.Name)) + return null; + + var existing = await _db.Locations.FirstOrDefaultAsync(location => location.ExternalId == room.Id); + if (existing != null) + { + existing.Name = room.Name; + existing.Room = room.NameShort; + existing.Building = room.Building?.Name ?? room.Building?.NameShort; + existing.Address = room.Building?.Address; + return existing; + } + + var location = new Location + { + Name = room.Name, + Room = room.NameShort, + Building = room.Building?.Name ?? room.Building?.NameShort, + Address = room.Building?.Address, + ExternalId = room.Id + }; + + _db.Locations.Add(location); + await _db.SaveChangesAsync(); + return location; + } + + private static string? GetEventRoomId(ModeusEventsResponse events, string eventId) => + events.Embedded?.EventRooms? + .Select(eventRoom => new + { + EventId = GetHrefId(eventRoom.Links?.Event?.Href), + RoomId = GetHrefId(eventRoom.Links?.Room?.Href) + }) + .FirstOrDefault(link => link.EventId == eventId) + ?.RoomId; + + private async Task> LoadRoomCapacityLookupAsync() + { + try + { + var rooms = await _modeus.SearchRoomsAsync(); + return BuildRoomCapacityLookup(rooms.RoomItems); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not load room capacities from Modeus rooms search."); + return new Dictionary(); + } + } + + private static IReadOnlyDictionary BuildRoomCapacityLookup(IEnumerable? rooms) + { + var result = new Dictionary(); + foreach (var room in rooms ?? []) + { + var capacity = NormalizeCapacity(room.WorkingCapacity) ?? NormalizeCapacity(room.TotalCapacity); + if (!string.IsNullOrWhiteSpace(room.Id) && capacity.HasValue) + result.TryAdd(room.Id, capacity.Value); + } + + return result; + } + + private static int? GetRoomCapacity(IReadOnlyDictionary roomCapacityById, string? roomId) => + !string.IsNullOrWhiteSpace(roomId) && roomCapacityById.TryGetValue(roomId, out var capacity) + ? capacity + : null; + + private static int? GetEventTeamSize(ModeusEventsResponse events, string eventId) => + NormalizeCapacity(events.Embedded?.EventTeams? + .FirstOrDefault(team => team.EventId == eventId)?.Size); + + private static int? NormalizeCapacity(int? capacity) => + capacity is > 0 ? capacity : null; + + private static string BuildModeusTeacherEmail(string personId) => + $"modeus-{personId}@modeus.local".ToLowerInvariant(); + + private static string? BuildPersonFullName(ModeusPerson? person) + { + if (person == null) + return null; + + if (!string.IsNullOrWhiteSpace(person.FullName)) + return person.FullName.Trim(); + + var parts = new[] { person.LastName, person.FirstName, person.MiddleName } + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(part => part!.Trim()); + + var fullName = string.Join(" ", parts); + return string.IsNullOrWhiteSpace(fullName) ? null : fullName; + } + + private static IReadOnlyList BuildErrorDetails( + Exception exception, + string stage, + int created, + int updated, + int skipped, + IReadOnlyList context) + { + var details = new List + { + $"stage={stage}", + $"exceptionType={exception.GetType().FullName}", + $"message={exception.Message}", + $"partialResult=created:{created}, updated:{updated}, skipped:{skipped}" + }; + + if (exception is HttpRequestException httpException && httpException.StatusCode.HasValue) + details.Add($"httpStatus={(int)httpException.StatusCode.Value} {httpException.StatusCode.Value}"); + + if (exception.InnerException != null) + details.Add($"innerException={exception.InnerException.GetType().FullName}: {exception.InnerException.Message}"); + + details.AddRange(context); + return details; + } + + private static string BuildScheduleRequestJson(SyncScheduleRequest request) + { + var body = new Dictionary + { + ["size"] = request.Size is > 0 ? request.Size.Value : 900, + ["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); + + return JsonSerializer.Serialize(body); + } + + private static void AddNonEmpty( + IDictionary body, + string key, + IReadOnlyList? values) + { + if (values is { Count: > 0 }) + body[key] = values; + } + + private static string? GetHrefId(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + return null; + + var index = href.LastIndexOf('/'); + return index >= 0 && index < href.Length - 1 + ? href[(index + 1)..] + : href; + } + + private static DateTime EnsureUtc(DateTime value) => + value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Local).ToUniversalTime() + }; } diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index 5e691f3..30cdd95 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -1,10 +1,12 @@ using Microsoft.EntityFrameworkCore; using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Users; using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; using UniVerse.Domain.Enums; using UniVerse.Domain.Exceptions; +using UniVerse.Domain.Services; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; @@ -22,14 +24,18 @@ public class UserService : IUserService public async Task GetByIdAsync(int id) { - var user = await _db.Users.FindAsync(id) + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); - return user.ToDto(_gamification.CalculateLevel(user.Xp)); + return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)); } public async Task UpdateProfileAsync(int id, UpdateUserRequest request) { - var user = await _db.Users.FindAsync(id) + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); if (request.DisplayName != null) user.DisplayName = request.DisplayName; @@ -37,7 +43,8 @@ public class UserService : IUserService user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); - return user.ToDto(_gamification.CalculateLevel(user.Xp)); + await _gamification.CheckAndAwardAchievementsAsync(id); + return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)); } public async Task GetStatsAsync(int id) @@ -49,13 +56,54 @@ public class UserService : IUserService var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == id && e.Attended); var reviews = await _db.Reviews.CountAsync(r => r.UserId == id); var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id); + var activeEnrollments = await _db.LectureEnrollments + .CountAsync(e => e.UserId == id && !e.Attended); + + var level = await _gamification.CalculateLevelAsync(user.Xp); + var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp); + var slotLimit = EnrollmentSlotPolicy.GetLimitForLevel(level); + var slotRules = EnrollmentSlotPolicy.Rules + .Select(rule => new EnrollmentSlotRuleDto(rule.Level, rule.Slots)) + .ToList(); return new UserStatsDto( totalLectures, attended, reviews, - user.Xp, user.Coins, _gamification.CalculateLevel(user.Xp), achievements + user.Xp, user.Coins, level, achievements, + levelProgress.CurrentLevelXp, levelProgress.NextLevelXp, + activeEnrollments, slotLimit, slotRules ); } + public async Task> GetEnrollmentsAsync(int id, PaginationRequest pagination) + { + if (!await _db.Users.AnyAsync(u => u.Id == id)) + throw new NotFoundException("User", id); + + var query = _db.LectureEnrollments + .Where(e => e.UserId == id) + .Include(e => e.Lecture) + .ThenInclude(l => l.Course) + .Include(e => e.Lecture) + .ThenInclude(l => l.Teacher) + .Include(e => e.Lecture) + .ThenInclude(l => l.Location) + .Include(e => e.Lecture) + .ThenInclude(l => l.Enrollments); + + var total = await query.CountAsync(); + var enrollments = await query + .OrderBy(e => e.Lecture.StartsAt) + .Skip((pagination.Page - 1) * pagination.PageSize) + .Take(pagination.PageSize) + .ToListAsync(); + + return PagedResult.Create( + enrollments.Select(e => e.Lecture.ToDto(isEnrolled: true)).ToList(), + total, + pagination.Page, + pagination.PageSize); + } + public async Task> GetAllAsync(UserFilterRequest filter) { var query = _db.Users.AsQueryable(); @@ -68,8 +116,15 @@ public class UserService : IUserService (u.DisplayName != null && u.DisplayName.ToLower().Contains(search))); } + query = query.Include(u => u.Roles); + if (filter.Role.HasValue) - query = query.Where(u => u.Role == filter.Role.Value); + { + var role = filter.Role.Value; + query = query.Where(u => + u.Roles.Count == 1 && + u.Roles.Any(ur => ur.Role == role)); + } if (filter.IsActive.HasValue) query = query.Where(u => u.IsActive == filter.IsActive.Value); @@ -82,15 +137,34 @@ public class UserService : IUserService .Take(filter.PageSize) .ToListAsync(); - var items = users.Select(u => u.ToDto(_gamification.CalculateLevel(u.Xp))).ToList(); + var items = new List(users.Count); + foreach (var user in users) + items.Add(user.ToDto(await _gamification.CalculateLevelAsync(user.Xp))); + return PagedResult.Create(items, total, filter.Page, filter.PageSize); } - public async Task SetRoleAsync(int id, UserRole role) + public async Task SetRolesAsync(int id, IReadOnlyCollection roles) { - var user = await _db.Users.FindAsync(id) + var normalizedRoles = roles.Distinct().ToList(); + if (normalizedRoles.Count == 0) + throw new ForbiddenException("At least one role is required."); + + var user = await _db.Users + .Include(u => u.Roles) + .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); - user.Role = role; + + var existing = user.Roles.Select(r => r.Role).ToHashSet(); + var toRemove = user.Roles.Where(r => !normalizedRoles.Contains(r.Role)).ToList(); + foreach (var item in toRemove) + user.Roles.Remove(item); + + var toAdd = normalizedRoles.Where(r => !existing.Contains(r)).ToList(); + foreach (var role in toAdd) + user.Roles.Add(new Domain.Entities.UserRoleAssignment { UserId = user.Id, Role = role }); + + await EnsureProfilesForRolesAsync(user.Id, normalizedRoles); user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } @@ -103,4 +177,21 @@ public class UserService : IUserService user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } + + private async Task EnsureProfilesForRolesAsync(int userId, IReadOnlyCollection roles) + { + if (roles.Contains(UserRole.Student)) + { + var hasStudentProfile = await _db.StudentProfiles.AnyAsync(p => p.UserId == userId); + if (!hasStudentProfile) + _db.StudentProfiles.Add(new Domain.Entities.StudentProfile { UserId = userId }); + } + + if (roles.Contains(UserRole.Teacher)) + { + var hasTeacherProfile = await _db.TeacherProfiles.AnyAsync(p => p.UserId == userId); + if (!hasTeacherProfile) + _db.TeacherProfiles.Add(new Domain.Entities.TeacherProfile { UserId = userId }); + } + } } diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj index a0ba73b..fa24205 100644 --- a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -9,9 +9,10 @@ - - + + + diff --git a/backend/UniVerse.ServiceDefaults/Extensions.cs b/backend/UniVerse.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..224d3d9 --- /dev/null +++ b/backend/UniVerse.ServiceDefaults/Extensions.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/backend/UniVerse.ServiceDefaults/UniVerse.ServiceDefaults.csproj b/backend/UniVerse.ServiceDefaults/UniVerse.ServiceDefaults.csproj new file mode 100644 index 0000000..64c017e --- /dev/null +++ b/backend/UniVerse.ServiceDefaults/UniVerse.ServiceDefaults.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + + + diff --git a/backend/UniVerse.sln b/backend/UniVerse.sln index 5b76058..1844b37 100644 --- a/backend/UniVerse.sln +++ b/backend/UniVerse.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Api", "UniVerse.Api\UniVerse.Api.csproj", "{7D214ABB-8402-4FDD-9B88-D357F2A400C8}" EndProject @@ -8,27 +8,108 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Application", "Uni EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Infrastructure", "UniVerse.Infrastructure\UniVerse.Infrastructure.csproj", "{A1B2C3D4-1111-2222-3333-444455558888}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.AppHost", "UniVerse.AppHost\UniVerse.AppHost.csproj", "{CC38B044-852A-4E9C-AB35-EF7E35088490}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.ServiceDefaults", "UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj", "{28475301-CBE1-4AE9-937F-8FD89E3EA6F0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Api.Tests", "UniVerse.Api.Tests\UniVerse.Api.Tests.csproj", "{AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x64.Build.0 = Debug|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|x86.Build.0 = Debug|Any CPU {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|Any CPU.Build.0 = Release|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x64.ActiveCfg = Release|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x64.Build.0 = Release|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x86.ActiveCfg = Release|Any CPU + {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Release|x86.Build.0 = Release|Any CPU {A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.Build.0 = Release|Any CPU {A1B2C3D4-1111-2222-3333-444455557777}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-1111-2222-3333-444455557777}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Release|x86.Build.0 = Release|Any CPU {A1B2C3D4-1111-2222-3333-444455558888}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-1111-2222-3333-444455558888}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Release|x86.Build.0 = Release|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x64.ActiveCfg = Debug|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x64.Build.0 = Debug|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x86.ActiveCfg = Debug|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Debug|x86.Build.0 = Debug|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|Any CPU.Build.0 = Release|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x64.ActiveCfg = Release|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x64.Build.0 = Release|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x86.ActiveCfg = Release|Any CPU + {CC38B044-852A-4E9C-AB35-EF7E35088490}.Release|x86.Build.0 = Release|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x64.ActiveCfg = Debug|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x64.Build.0 = Debug|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x86.ActiveCfg = Debug|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Debug|x86.Build.0 = Debug|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|Any CPU.Build.0 = Release|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x64.ActiveCfg = Release|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x64.Build.0 = Release|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x86.ActiveCfg = Release|Any CPU + {28475301-CBE1-4AE9-937F-8FD89E3EA6F0}.Release|x86.Build.0 = Release|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x64.ActiveCfg = Debug|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x64.Build.0 = Debug|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x86.ActiveCfg = Debug|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Debug|x86.Build.0 = Debug|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|Any CPU.Build.0 = Release|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x64.ActiveCfg = Release|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x64.Build.0 = Release|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x86.ActiveCfg = Release|Any CPU + {AC5CE153-A57C-4A6F-B7B6-7DBAC25E0B84}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 0000000..b6ec100 --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,79 @@ +services: + app: + container_name: UniVerse + image: git.zetcraft.ru/serega404/universe/backend:main + restart: always + ports: + - "8080:8080" + environment: + - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true + + - AzureAd:Instance=${AzureAd_Instance:-https://login.microsoftonline.com/} + - AzureAd:TenantId=${AzureAd_TenantId:-sfedu.ru} + - AzureAd:ClientId=${AzureAd_ClientId} + - AzureAd:ClientSecret=${AzureAd_ClientSecret} + - AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com} + - AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc} + # https:///api/v1/auth/callback/microsoft + - AzureAd:RedirectUri=${AzureAd_RedirectUri} + - AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-} + + - Jwt:Secret=${JWT_SECRET} + - Jwt:Issuer=${JWT_ISSUER:-UniVerse} + - Jwt:Audience=${JWT_AUDIENCE:-UniVerse} + - Jwt:AccessTokenExpirationMinutes=${JWT_ACCESS_TOKEN_EXPIRATION_MINUTES:-30} + - Jwt:RefreshTokenExpirationDays=${JWT_REFRESH_TOKEN_EXPIRATION_DAYS:-30} + + - Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + + - Llm:BaseUrl=${LLM_BASE_URL} + - Llm:ApiKey=${LLM_API_KEY} + - Llm:Model=${LLM_MODEL} + + - ModeusApi:BaseUrl=${MODEUS_API_BASE_URL} + - ModeusApi:ApiKey=${MODEUS_API_KEY} + + - Email:Smtp:Host=${EMAIL_SMTP_HOST} + - Email:Smtp:Port=${EMAIL_SMTP_PORT:-587} + - Email:Smtp:EnableSsl=${EMAIL_SMTP_ENABLE_SSL:-true} + - Email:Smtp:UserName=${EMAIL_SMTP_USERNAME} + - Email:Smtp:Password=${EMAIL_SMTP_PASSWORD} + - Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local} + - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} + + - ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} + networks: + - backend + - frontend + + db: + image: postgres:18-alpine + restart: always + ports: + - "5432" + volumes: + - database_data:/var/lib/postgresql + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DATABASE:-universe} + networks: + - backend + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}" ] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + + frontend: + image: git.zetcraft.ru/serega404/universe/frontend:main + restart: unless-stopped + ports: + - "80" + networks: + - frontend + +networks: + frontend: + backend: diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100644 index 0000000..567d6a4 --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,71 @@ +services: + app: + container_name: UniVerse + build: + context: ./backend + dockerfile: Dockerfile + restart: always + ports: + - "8088:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true + + - AzureAd:Instance=${AzureAd_Instance:-https://login.microsoftonline.com/} + - AzureAd:TenantId=${AzureAd_TenantId:-sfedu.ru} + - AzureAd:ClientId=${AzureAd_ClientId} + - AzureAd:ClientSecret=${AzureAd_ClientSecret} + - AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com} + - AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc} + - AzureAd:RedirectUri=${AzureAd_RedirectUri:-http://localhost:8088/api/v1/auth/callback/microsoft} + - AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-} + + - Jwt:Secret=${JWT_SECRET} + - Jwt:Issuer=${JWT_ISSUER:-UniVerse} + - Jwt:Audience=${JWT_AUDIENCE:-UniVerse} + - Jwt:AccessTokenExpirationMinutes=${JWT_EXPIRE_DAYS:-30} + - Jwt:RefreshTokenExpirationDays=${JWT_EXPIRE_DAYS:-30} + + - Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + + - Llm:BaseUrl=${LLM_BASE_URL} + - Llm:ApiKey=${LLM_API_KEY} + - Llm:Model=${LLM_MODEL} + + - ModeusApi:BaseUrl=${MODEUS_API_BASE_URL} + - ModeusApi:ApiKey=${MODEUS_API_KEY} + + - Email:Smtp:Host=${EMAIL_SMTP_HOST} + - Email:Smtp:Port=${EMAIL_SMTP_PORT:-587} + - Email:Smtp:EnableSsl=${EMAIL_SMTP_ENABLE_SSL:-true} + - Email:Smtp:UserName=${EMAIL_SMTP_USERNAME} + - Email:Smtp:Password=${EMAIL_SMTP_PASSWORD} + - Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local} + - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} + - ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} + + db: + image: postgres:18-alpine + restart: always + ports: + - "5432" + volumes: + - database_data:/var/lib/postgresql + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DATABASE} + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}" ] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "80" + restart: unless-stopped diff --git a/docs/backend.md b/docs/backend.md new file mode 100644 index 0000000..424a9cf --- /dev/null +++ b/docs/backend.md @@ -0,0 +1,581 @@ +# Backend UniVerse + +Этот документ помогает быстро вкатиться в backend UniVerse: понять слои, основные сущности, API, фоновые процессы, интеграции и точки расширения. Диаграммы написаны в Mermaid, поэтому их можно смотреть прямо в IDE/Markdown-просмотрщике с поддержкой Mermaid. + +## Что делает backend + +Backend UniVerse обслуживает платформу открытых лекций: + +- хранит пользователей, роли, профили студентов и преподавателей; +- ведет каталог курсов, тегов, локаций и лекций; +- позволяет студентам записываться на лекции и оставлять отзывы; +- анализирует отзывы через LLM в фоне; +- начисляет монеты, XP и достижения; +- отправляет и планирует уведомления; +- синхронизирует расписание и аудитории из Modeus; +- публикует REST API и Swagger/OpenAPI. + +Основной solution: [`backend/UniVerse.sln`](../backend/UniVerse.sln). + +## Быстрый старт для чтения кода + +Начинай с этих файлов: + +1. [`backend/UniVerse.Api/Program.cs`](../backend/UniVerse.Api/Program.cs) - composition root: DI, middleware, auth, Swagger, фоновые сервисы. +2. [`backend/UniVerse.Api/Controllers`](../backend/UniVerse.Api/Controllers) - HTTP API и роли доступа. +3. [`backend/UniVerse.Application/Interfaces`](../backend/UniVerse.Application/Interfaces) - контракты бизнес-сервисов. +4. [`backend/UniVerse.Infrastructure/Services`](../backend/UniVerse.Infrastructure/Services) - реализация сценариев. +5. [`backend/UniVerse.Domain/Entities`](../backend/UniVerse.Domain/Entities) - доменная модель. +6. [`backend/UniVerse.Infrastructure/Data/AppDbContext.cs`](../backend/UniVerse.Infrastructure/Data/AppDbContext.cs) и [`Configurations`](../backend/UniVerse.Infrastructure/Data/Configurations) - схема PostgreSQL. + +## Проекты и ответственность + +```mermaid +flowchart LR + Client[Frontend / API client] + Api[UniVerse.Api
Controllers, middleware, hosted services] + App[UniVerse.Application
DTO, interfaces, mappings] + Infra[UniVerse.Infrastructure
EF Core, services, external clients] + Domain[UniVerse.Domain
Entities, enums, exceptions] + Db[(PostgreSQL)] + Llm[OpenAI-compatible LLM] + Modeus[Modeus / schedule.rdcenter.ru] + Email[SMTP] + + Client --> Api + Api --> App + Api --> Infra + Infra --> App + Infra --> Domain + App --> Domain + Infra --> Db + Infra --> Llm + Infra --> Modeus + Infra --> Email +``` + +| Проект | Что внутри | Зависит от | +| --- | --- | --- | +| `UniVerse.Api` | ASP.NET Core Web API: контроллеры, JWT, CORS, Swagger, middleware, background services | `Application`, `Infrastructure`, `ServiceDefaults` | +| `UniVerse.Application` | DTO, интерфейсы сервисов, mapping extensions | `Domain` | +| `UniVerse.Domain` | Entities, enums, доменные исключения | ничего | +| `UniVerse.Infrastructure` | EF Core, PostgreSQL, сервисы, LLM/Modeus/SMTP-клиенты, Quartz scheduler | `Domain`, `Application` | +| `UniVerse.AppHost` | .NET Aspire host для совместного запуска API и frontend dev server | API и frontend как внешняя команда | +| `UniVerse.Api.Tests` | xUnit-тесты авторизации, Swagger и геймификации | API | + +## Жизненный цикл HTTP-запроса + +```mermaid +sequenceDiagram + participant C as Client + participant M as ASP.NET middleware + participant Auth as JWT auth + participant Ctrl as Controller + participant Svc as Application interface / Infrastructure service + participant Db as AppDbContext + + C->>M: HTTP request /api/v1/... + M->>M: RequestLoggingMiddleware + M->>M: ExceptionHandlingMiddleware + M->>Auth: UseAuthentication / UseAuthorization + Auth-->>Ctrl: ClaimsPrincipal with roles + Ctrl->>Svc: DTO/request + current user id + Svc->>Db: EF Core query/command + Db-->>Svc: entities + Svc-->>Ctrl: DTO/result + Ctrl-->>C: JSON response +``` + +Пайплайн в [`Program.cs`](../backend/UniVerse.Api/Program.cs): + +- `RequestLoggingMiddleware` пишет входящие запросы. +- `ExceptionHandlingMiddleware` переводит доменные исключения в `application/problem+json`. +- CORS берет origins из `Cors:Origins`. +- JWT Bearer проверяет issuer, audience, lifetime и signing key. +- Swagger доступен в `Development` по `/api/docs`, JSON - `/api/docs/v1/swagger.json`. + +## Конфигурация + +Основные секции: + +| Секция | Назначение | +| --- | --- | +| `ConnectionStrings:DefaultConnection` | подключение к PostgreSQL | +| `Jwt:*` | issuer, audience, secret, TTL access/refresh токенов | +| `AzureAd:*` | Microsoft Entra ID OAuth flow | +| `Cors:Origins` | разрешенные origins frontend | +| `Llm:*` | OpenAI-compatible endpoint, API key, model | +| `ModeusApi:*` | endpoint и ключ внешнего расписания | +| `Email:Smtp:*` | SMTP-провайдер уведомлений | +| `Gamification:XpThresholds` | пороги уровней по XP | +| `Aspire:Enabled` | включает service defaults при запуске через AppHost | + +Для окружений удобнее задавать переменные в формате `Section__Key`, например: + +```bash +ConnectionStrings__DefaultConnection="Host=localhost;Port=5432;Database=universe;Username=postgres;Password=postgres" +Jwt__Secret="local-secret-at-least-32-characters" +Llm__ApiKey="..." +ModeusApi__ApiKey="..." +Email__Smtp__Host="smtp.example.com" +``` + +Важно: секреты не должны попадать в документацию, README, коммиты и логи. В dev-файлах могут быть локальные значения, но для реального запуска используй переменные окружения или secret storage. + +## Как запустить backend + +Из корня репозитория: + +```bash +cd backend +dotnet restore UniVerse.sln +dotnet ef database update --project UniVerse.Infrastructure --startup-project UniVerse.Api +dotnet run --project UniVerse.Api --launch-profile http +``` + +По launch profile API слушает `http://localhost:5019`, Swagger UI: `http://localhost:5019/api/docs`. + +Тесты: + +```bash +cd backend +dotnet test UniVerse.sln +``` + +Docker compose есть в корне: + +- [`docker-compose-test.yml`](../docker-compose-test.yml) - сборка локальных образов; +- [`docker-compose-prod.yml`](../docker-compose-prod.yml) - запуск published images. + +## Авторизация и роли + +Роли заданы enum `UserRole`: `Student`, `Teacher`, `Admin`. + +Токены: + +- access token - JWT Bearer, передается в `Authorization: Bearer `; +- refresh token - HttpOnly cookie `refreshToken`; +- logout отзывает refresh token в БД, но уже выданный access token живет до истечения TTL. + +Вход: + +```mermaid +sequenceDiagram + participant U as User browser + participant F as Frontend + participant A as UniVerse.Api + participant MS as Microsoft Entra ID + participant DB as PostgreSQL + + U->>F: Нажимает "Войти" + F->>A: GET /api/v1/auth/login/microsoft?returnUrl=... + A->>A: Генерирует state и cookie + A-->>U: 302 redirect на Microsoft + U->>MS: OAuth authorize + MS-->>A: GET /api/v1/auth/callback/microsoft?code&state + A->>A: Проверяет state + A->>MS: Обменивает code на token + A->>DB: Upsert user, roles, refresh token + A-->>U: refreshToken cookie + redirect с access token во fragment +``` + +Dev-вход доступен только в `Development`: + +```http +POST /api/v1/auth/login/dev +Content-Type: application/json + +{ + "email": "student@example.com", + "displayName": "Student", + "roles": ["Student"] +} +``` + +## Карта API + +Базовый префикс: `/api/v1`. + +| Область | Endpoint | Доступ | Назначение | +| --- | --- | --- | --- | +| Auth | `POST /auth/login/microsoft` | public | обмен authorization code на токены | +| Auth | `GET /auth/login/microsoft` | public | server-driven redirect на Microsoft | +| Auth | `GET /auth/callback/microsoft` | public | OAuth callback | +| Auth | `POST /auth/login/dev` | public, только Development | dev login | +| Auth | `POST /auth/refresh` | public + refresh cookie | обновить access token | +| Auth | `POST /auth/logout` | auth | отозвать refresh token | +| Auth | `GET /auth/me` | auth | текущий пользователь | +| Users | `GET /users` | Admin | список пользователей | +| Users | `GET /users/{id}` | auth | профиль пользователя | +| Users | `PUT /users/{id}` | владелец или Admin | обновить `displayName`, `avatarUrl` | +| Users | `GET /users/{id}/stats` | auth | XP, уровень, монеты, посещения | +| Users | `GET /users/{id}/reviews` | auth | отзывы пользователя | +| Users | `GET /users/{id}/achievements` | auth | достижения пользователя | +| Users | `GET /users/{id}/transactions` | владелец или Admin | история монет | +| Users | `PATCH /users/{id}/role` | Admin | заменить набор ролей | +| Users | `PATCH /users/{id}/active` | Admin | активировать/деактивировать пользователя | +| Courses | `GET /courses` | auth | список курсов с фильтрами | +| Courses | `GET /courses/{id}` | auth | курс с тегами | +| Courses | `POST /courses` | Admin | создать курс | +| Courses | `PUT /courses/{id}` | Admin | обновить курс | +| Courses | `DELETE /courses/{id}` | Admin | удалить курс | +| Courses | `POST /courses/{id}/tags` | Admin | привязать тег | +| Courses | `DELETE /courses/{id}/tags/{tagId}` | Admin | отвязать тег | +| Lectures | `GET /lectures` | auth | каталог лекций | +| Lectures | `GET /lectures/{id}` | auth | детальная карточка | +| Lectures | `POST /lectures` | Admin | создать лекцию | +| Lectures | `PUT /lectures/{id}` | Admin, Teacher | обновить лекцию | +| Lectures | `DELETE /lectures/{id}` | Admin | удалить лекцию | +| Lectures | `POST /lectures/{id}/enroll` | Student | записаться | +| Lectures | `DELETE /lectures/{id}/enroll` | Student | отменить запись | +| Lectures | `PATCH /lectures/{id}/attendance/{userId}` | Admin, Teacher | отметить посещение | +| Lectures | `GET /lectures/{id}/enrollments` | Admin, Teacher | записавшиеся | +| Lectures | `GET /lectures/{id}/reviews` | auth | отзывы по лекции | +| Reviews | `POST /reviews` | Student | создать отзыв | +| Reviews | `GET /reviews/{id}` | auth | получить отзыв | +| Reviews | `PUT /reviews/{id}` | владелец | обновить отзыв и сбросить LLM-статус | +| Reviews | `DELETE /reviews/{id}` | владелец или Admin | удалить отзыв | +| Reviews | `GET /reviews/pending` | Admin | очередь LLM-анализа | +| Reviews | `POST /reviews/{id}/reanalyze` | Admin | поставить отзыв на повторный анализ | +| Tags | `GET /tags` | auth | список тегов | +| Tags | `GET /tags/tree` | auth | дерево тегов | +| Tags | `GET /tags/{id}` | auth | тег по id | +| Tags | `POST /tags` | Admin | создать тег | +| Tags | `PUT /tags/{id}` | Admin | обновить тег | +| Tags | `DELETE /tags/{id}` | Admin | удалить тег | +| Locations | `GET /locations` | auth | список локаций | +| Locations | `GET /locations/{id}` | auth | локация по id | +| Locations | `POST /locations` | Admin | создать локацию | +| Locations | `PUT /locations/{id}` | Admin | обновить локацию | +| Locations | `DELETE /locations/{id}` | Admin | удалить локацию | +| Achievements | `GET /achievements` | auth | каталог достижений | +| Achievements | `GET /achievements/{id}` | auth | достижение по id | +| Achievements | `POST /achievements` | Admin | создать достижение | +| Achievements | `PUT /achievements/{id}` | Admin | обновить достижение | +| Achievements | `DELETE /achievements/{id}` | Admin | удалить достижение | +| Notifications | `GET /notifications` | auth | уведомления текущего пользователя | +| Notifications | `PATCH /notifications/read-all` | auth | отметить свои уведомления прочитанными | +| Notifications | `POST /notifications/send` | Admin | отправить уведомление сразу | +| Notifications | `POST /notifications/schedule` | Admin | запланировать уведомление через Quartz | +| Sync | `POST /sync/schedule` | Admin | синхронизация лекций из Modeus | +| Sync | `POST /sync/rooms` | Admin | синхронизация аудиторий | +| Sync | `POST /sync/employees?fullname=...` | Admin | поиск сотрудников | +| Sync | `GET /sync/status` | Admin | статус последней синхронизации | + +## Доменная модель + +```mermaid +erDiagram + USERS ||--o{ USER_ROLES : has + USERS ||--o| STUDENT_PROFILES : may_have + USERS ||--o| TEACHER_PROFILES : may_have + USERS ||--o{ REFRESH_TOKENS : owns + USERS ||--o{ LECTURE_ENROLLMENTS : enrolls + USERS ||--o{ REVIEWS : writes + USERS ||--o{ USER_ACHIEVEMENTS : earns + USERS ||--o{ COIN_TRANSACTIONS : receives + USERS ||--o{ USER_NOTIFICATIONS : receives + + COURSES ||--o{ LECTURES : contains + COURSES ||--o{ COURSE_TAGS : tagged + TAGS ||--o{ COURSE_TAGS : assigned + TAGS ||--o{ TAGS : parent_of + + LOCATIONS ||--o{ LECTURES : hosts + LECTURES ||--o{ LECTURE_ENROLLMENTS : has + LECTURES ||--o{ REVIEWS : receives + USERS ||--o{ LECTURES : teaches + + ACHIEVEMENTS ||--o{ USER_ACHIEVEMENTS : awarded_as + REVIEWS ||--o{ COIN_TRANSACTIONS : may_reward + ACHIEVEMENTS ||--o{ COIN_TRANSACTIONS : may_reward +``` + +Ключевые таблицы и поля: + +| Сущность | Смысл | +| --- | --- | +| `users` | аккаунт: email, display name, avatar, active flag, Microsoft id, XP, coins | +| `user_roles` | join table ролей; у пользователя может быть несколько ролей | +| `student_profiles`, `teacher_profiles` | дополнительные данные профиля | +| `refresh_tokens` | refresh token, expiry, revoked flag | +| `courses` | дисциплины/курсы; могут быть синхронизированы из Modeus | +| `tags` | иерархические теги с типом: institute/faculty/subject/topic/etc | +| `course_tags` | связь many-to-many между курсами и тегами | +| `lectures` | открытые лекции: курс, преподаватель, локация, время, формат, вместимость | +| `lecture_enrollments` | записи студентов, флаг посещения | +| `reviews` | отзывы студентов и результат LLM-анализа | +| `achievements` | каталог достижений и условие получения | +| `user_achievements` | полученные пользователем достижения | +| `coin_transactions` | история начисления монет/XP | +| `user_notifications` | in-app уведомления пользователя | + +EF Core приводит имена таблиц и колонок к `snake_case` в [`AppDbContext`](../backend/UniVerse.Infrastructure/Data/AppDbContext.cs). PostgreSQL enums регистрируются для ролей, типов тегов, LLM-статусов, сентимента и типов транзакций. + +## Основные сценарии + +### Запись на лекцию + +```mermaid +sequenceDiagram + participant S as Student + participant C as LecturesController + participant LS as LectureService + participant GS as GamificationService + participant DB as PostgreSQL + + S->>C: POST /api/v1/lectures/{id}/enroll + C->>LS: EnrollAsync(lectureId, currentUserId) + LS->>DB: Load lecture with enrollments + LS->>LS: Проверить isOpen, capacity, duplicate + LS->>DB: Insert lecture_enrollment + LS->>GS: CheckAndAwardAchievementsAsync(userId) + GS->>DB: Проверить условия достижений + GS-->>C: done + C-->>S: 204 No Content +``` + +Условия: + +- лекция должна быть открыта (`IsOpen = true`); +- если `MaxEnrollments > 0`, число записей не должно превышать лимит; +- повторная запись дает `409 Conflict`; +- после записи проверяются достижения. + +### Посещение лекции + +`PATCH /api/v1/lectures/{id}/attendance/{userId}` доступен `Admin` и `Teacher`. Сервис меняет `LectureEnrollment.Attended`. Если `attended = true`, запускается проверка достижений. В текущей реализации отдельное начисление монет за посещение не вызывается напрямую, но достижения могут начислить награду. + +### Отзыв и LLM-анализ + +```mermaid +sequenceDiagram + participant S as Student + participant RC as ReviewsController + participant RS as ReviewService + participant BG as LlmProcessingBackgroundService + participant LLM as LlmAnalysisService + participant Client as LlmClient + participant G as GamificationService + participant DB as PostgreSQL + + S->>RC: POST /api/v1/reviews + RC->>RS: CreateAsync(userId, request) + RS->>DB: Insert review with LlmStatus=Pending + RS->>G: CheckAndAwardAchievementsAsync(userId) + RC-->>S: 201 ReviewDto + + loop каждые 2 минуты + BG->>LLM: ProcessPendingReviewsAsync() + LLM->>DB: взять до 10 Pending отзывов + LLM->>Client: AnalyzeReviewAsync(text, lecture context) + Client-->>LLM: qualityScore, sentiment, tags, isInformative + LLM->>DB: сохранить результат, LlmStatus=Analyzed + alt отзыв информативный + LLM->>G: AwardCoinsAsync(..., 10, ReviewReward) + end + LLM->>G: CheckAndAwardAchievementsAsync(userId) + end +``` + +LLM-запись хранит: + +- `LlmStatus`: `Pending`, `Analyzed`, `Rejected`; +- `Sentiment`: `Positive`, `Neutral`, `Negative`; +- `QualityScore`; +- `IsInformative`; +- `LlmTags`. + +Если LLM-клиент падает, статус остается `Pending`, а фоновый сервис попробует снова на следующем цикле. + +### Геймификация + +`GamificationService` делает две вещи: + +- начисляет монеты и XP через `AwardCoinsAsync`; +- проверяет каталог достижений через `CheckAndAwardAchievementsAsync`. + +Условия достижений хранятся строкой `type:value`, например: + +| Условие | Что проверяется | +| --- | --- | +| `first_activity:1` | есть запись, отзыв или посещение | +| `lectures_attended:N` | посещено минимум N лекций | +| `reviews_written:N` | написано минимум N отзывов | +| `lectures_registered:N` | оформлено минимум N записей | +| `active_registrations:N` | есть N будущих записей | +| `attendance_streak_weeks:N` | посещения N недель подряд | +| `coins_earned:N` | получено минимум N положительных монет | +| `level_reached:N` | достигнут уровень N | +| `profile_completed:1` | заполнены `DisplayName` и `AvatarUrl` | + +Каталог базовых достижений заполняется при старте через `AchievementCatalogHostedService` и [`AchievementCatalogSeeder`](../backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs). + +### Уведомления + +```mermaid +flowchart LR + Admin[Admin API request] + Achievement[Achievement earned] + NotificationService[NotificationService] + Db[(user_notifications)] + Provider[INotificationProvider] + Email[EmailNotificationProvider / SMTP] + Quartz[QuartzNotificationScheduler] + Job[NotificationJob] + + Admin -->|POST /notifications/send| NotificationService + Admin -->|POST /notifications/schedule| Quartz + Achievement --> NotificationService + NotificationService --> Db + NotificationService --> Provider + Provider --> Email + Quartz --> Job + Job --> NotificationService +``` + +Сейчас основной канал - `email`. In-app уведомления хранятся в `user_notifications`; пользователь может получить их через `GET /api/v1/notifications` и отметить все прочитанными через `PATCH /api/v1/notifications/read-all`. + +### Синхронизация Modeus + +```mermaid +flowchart TD + Admin[Admin] --> Sync[SyncController] + Sync --> Service[ScheduleSyncService] + Service --> Modeus[ModeusApiClient] + Modeus --> External[schedule.rdcenter.ru] + Service --> Courses[(courses)] + Service --> Lectures[(lectures)] + Service --> Locations[(locations)] + Service --> Status[static _lastStatus] +``` + +`POST /api/v1/sync/schedule`: + +- запрашивает события из Modeus; +- upsert-ит курс по `ExternalId`; +- upsert-ит локацию из комнаты события; +- upsert-ит лекцию по `ExternalId`; +- сохраняет счетчики `created`, `updated`, `skipped`. + +`POST /api/v1/sync/rooms` импортирует аудитории в `locations`. `POST /api/v1/sync/employees` только ищет сотрудников и не создает пользователей автоматически. + +Статус последней синхронизации хранится в памяти процесса (`static _lastStatus`), поэтому после рестарта API он снова `idle`. + +## Ошибки и ответы + +Доменные исключения: + +| Исключение | HTTP | +| --- | --- | +| `NotFoundException` | `404 Not Found` | +| `ForbiddenException` | `403 Forbidden` | +| `ConflictException` | `409 Conflict` | +| `UnauthorizedAccessException` | `401 Unauthorized` | +| неизвестная ошибка | `500 Internal Server Error` | + +Формат ошибки: + +```json +{ + "type": "https://httpstatuses.com/404", + "title": "Not Found", + "status": 404, + "detail": "Lecture with id 10 was not found.", + "traceId": "..." +} +``` + +## Фильтрация и пагинация + +Общий формат пагинации: + +```json +{ + "items": [], + "total": 0, + "page": 1, + "pageSize": 20, + "totalPages": 0 +} +``` + +Частые query-параметры: + +- `page`, `pageSize` - для пагинации; +- `search` - текстовый поиск; +- `tagId`, `courseId`, `teacherId` - фильтры каталога; +- `dateFrom`, `dateTo` - даты лекций; +- `format` - `Online` или `Offline`; +- `isOpen` - открыта ли запись. + +## Swagger и тесты + +Swagger: + +- UI: `/api/docs`; +- JSON: `/api/docs/v1/swagger.json`; +- `AuthorizeOperationFilter` добавляет Bearer security только к защищенным endpoint-ам и дописывает требуемые роли в описание. + +Тесты: + +- `EndpointAuthorizationTests` проверяет, что защищенные endpoint-ы возвращают `401` анонимам и `403` неправильным ролям; +- `SwaggerDocumentTests` проверяет генерацию OpenAPI и security metadata; +- `GamificationServiceTests` проверяет правила достижений/наград. + +## Как добавить новый backend-сценарий + +Обычный путь изменения: + +1. Добавить или изменить entity/enum в `UniVerse.Domain`, если меняется модель данных. +2. Добавить DTO в `UniVerse.Application/DTOs`. +3. Добавить контракт в `UniVerse.Application/Interfaces`. +4. Реализовать сервис в `UniVerse.Infrastructure/Services`. +5. Зарегистрировать сервис в DI в `Program.cs`. +6. Добавить endpoint в controller. +7. Если меняется БД, создать EF migration через `dotnet ef`, не редактировать миграции вручную. +8. Добавить тесты авторизации/бизнес-логики. +9. Проверить Swagger JSON и, если нужно, frontend-контракт. + +Команда миграции: + +```bash +cd backend +dotnet ef migrations add NameOfMigration --project UniVerse.Infrastructure --startup-project UniVerse.Api +dotnet ef database update --project UniVerse.Infrastructure --startup-project UniVerse.Api +``` + +## Места, на которые стоит обратить внимание + +- `GET /api/v1/users/{id}/enrollments` сейчас проверяет доступ, но возвращает пустой `200 OK`; полноценная выдача записей пользователя еще не реализована. +- `CreatedAtAction` в нескольких `POST` endpoint-ах передает `{ id = 0 }`, хотя созданная сущность уже известна. Если клиенту важен `Location` header, это стоит поправить. +- `SyncStatusDto` хранится в памяти процесса, не в БД. +- `ReviewLlmStatus.Rejected` есть в enum, но текущий `LlmAnalysisService` не переводит отзывы в rejected. +- В `CourseTag`, `LectureEnrollment`, `UserAchievement` entity есть поле `Id`, но `AppDbContext.OnModelCreating` дополнительно задает составные ключи для этих сущностей после применения конфигураций. При изменении модели обязательно сверяй snapshot/миграцию. +- `Secure = true` у refresh cookie означает, что для cookie-flow в браузере нужен HTTPS. В локальной разработке через plain HTTP это может влиять на поведение cookie. + +## Ментальная модель + +Если совсем коротко: + +```mermaid +flowchart LR + Auth[Auth: Microsoft/dev login] --> User[User + roles] + User --> Catalog[Catalog: courses, tags, locations] + Catalog --> Lecture[Lectures] + User --> Enrollment[Enrollments] + Lecture --> Enrollment + Enrollment --> Attendance[Attendance] + Lecture --> Review[Reviews] + Review --> Llm[LLM analysis] + Llm --> Rewards[Coins + XP] + Attendance --> Achievements[Achievements] + Enrollment --> Achievements + Review --> Achievements + Rewards --> Achievements + Achievements --> Notifications[Notifications] + Modeus[Modeus sync] --> Catalog + Modeus --> Lecture +``` + +Почти все пользовательские сценарии проходят через пользователя с ролью, меняют лекции/записи/отзывы, а потом запускают геймификацию. Внешние интеграции - LLM, Modeus и SMTP - изолированы в Infrastructure и подключены через интерфейсы Application. diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..3b510aa --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,8 @@ +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 100 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c649528 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=/api +VITE_AUTH_RETURN_URL=/auth/callback diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..cd68f14 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,39 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + +.eslintcache + +# Cypress +/cypress/videos/ +/cypress/screenshots/ + +# Vitest +__screenshots__/ + +# Vite +*.timestamp-*-*.mjs diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..d5648b9 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"], + "env": { + "browser": true + }, + "categories": { + "correctness": "error" + } +} diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..29a2402 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..3f84126 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "Vue.volar", + "dbaeumer.vscode-eslint", + "EditorConfig.EditorConfig", + "oxc.oxc-vscode", + "esbenp.prettier-vscode" + ] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6a0aba9 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:24-slim AS base + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +FROM base AS prod + +WORKDIR /app +COPY pnpm-lock.yaml ./ +RUN pnpm fetch --prod + +COPY . ./ +RUN pnpm run build + +FROM nginx:1.30-alpine +COPY --from=prod /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..f2916fd --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,66 @@ +# universe + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Recommended Browser Setup + +- Chromium-based browsers (Chrome, Edge, Brave, etc.): + - [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd) + - [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters) +- Firefox: + - [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/) + - [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/) + +## Type Support for `.vue` Imports in TS + +TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +pnpm install +``` + +## Запуск вместе с backend (Aspire) + +Если запускать приложение через `backend/UniVerse.AppHost`, то фронтенд (Vite dev server) поднимается автоматически. + +1) Установить зависимости фронтенда: + +```sh +pnpm -C frontend install +``` + +2) Запустить Aspire AppHost: + +```sh +dotnet run --project backend/UniVerse.AppHost/UniVerse.AppHost.csproj +``` + +Обычно фронтенд слушает `http://localhost:5173` (если порт занят — Vite выберет следующий свободный). + +### Compile and Hot-Reload for Development + +```sh +pnpm dev +``` + +### Type-Check, Compile and Minify for Production + +```sh +pnpm build +``` + +### Lint with [ESLint](https://eslint.org/) + +```sh +pnpm lint +``` diff --git a/frontend/env.d.ts b/frontend/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts new file mode 100644 index 0000000..4b4d3f3 --- /dev/null +++ b/frontend/eslint.config.ts @@ -0,0 +1,28 @@ +import { globalIgnores } from 'eslint/config' +import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' +import pluginVue from 'eslint-plugin-vue' +import pluginOxlint from 'eslint-plugin-oxlint' +import skipFormatting from 'eslint-config-prettier/flat' +import vueScopedCss from 'eslint-plugin-vue-scoped-css' + +// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: +// import { configureVueProject } from '@vue/eslint-config-typescript' +// configureVueProject({ scriptLangs: ['ts', 'tsx'] }) +// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup + +export default defineConfigWithVueTs( + { + name: 'app/files-to-lint', + files: ['**/*.{vue,ts,mts,tsx}'], + }, + + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), + + ...pluginVue.configs['flat/essential'], + vueTsConfigs.recommended, + ...vueScopedCss.configs.recommended, + + ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'), + + skipFormatting, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ed3f053 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + UniVerse + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100755 index 0000000..cd5feb0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,32 @@ +resolver 127.0.0.11; + +upstream backend_app { + zone backend_app 64k; + server app:8080 resolve; +} + +server { + listen 80 default_server; + gzip on; + gzip_types text/plain application/xml text/css application/javascript; + gzip_min_length 1000; + client_max_body_size 0; + + location / { + root /usr/share/nginx/html; + include /etc/nginx/mime.types; + try_files $uri /index.html; + } + + location /api { + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 1m; + proxy_connect_timeout 1m; + proxy_pass http://backend_app/api; + } + +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..759a3bb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "universe", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "dev:aspire": "vite --host 0.0.0.0 --port 5173", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build", + "lint": "run-s lint:*", + "lint:oxlint": "oxlint . --fix", + "lint:eslint": "eslint . --fix --cache", + "format": "prettier --write --experimental-cli src/" + }, + "dependencies": { + "pinia": "^3.0.4", + "vue": "^3.5.32", + "vue-router": "^5.0.6" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.4", + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.6", + "@vue/eslint-config-typescript": "^14.7.0", + "@vue/tsconfig": "^0.9.1", + "eslint": "^10.2.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-oxlint": "~1.60.0", + "eslint-plugin-vue": "~10.8.0", + "eslint-plugin-vue-scoped-css": "^3.1.0", + "jiti": "^2.6.1", + "npm-run-all2": "^8.0.4", + "oxlint": "~1.60.0", + "prettier": "3.8.3", + "typescript": "~6.0.0", + "vite": "^8.0.8", + "vite-plugin-vue-devtools": "^8.1.1", + "vue-tsc": "^3.2.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..e9d1756 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3188 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + vue: + specifier: ^3.5.32 + version: 3.5.34(typescript@6.0.3) + vue-router: + specifier: ^5.0.6 + version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) + devDependencies: + '@tsconfig/node24': + specifier: ^24.0.4 + version: 24.0.4 + '@types/node': + specifier: ^24.12.2 + version: 24.12.2 + '@vitejs/plugin-vue': + specifier: ^6.0.6 + version: 6.0.6(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3)) + '@vue/eslint-config-typescript': + specifier: ^14.7.0 + version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + '@vue/tsconfig': + specifier: ^0.9.1 + version: 0.9.1(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + eslint: + specifier: ^10.2.1 + version: 10.3.0(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.3.0(jiti@2.7.0)) + eslint-plugin-oxlint: + specifier: ~1.60.0 + version: 1.60.0(oxlint@1.60.0) + eslint-plugin-vue: + specifier: ~10.8.0 + version: 10.8.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))) + eslint-plugin-vue-scoped-css: + specifier: ^3.1.0 + version: 3.1.0(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))) + jiti: + specifier: ^2.6.1 + version: 2.7.0 + npm-run-all2: + specifier: ^8.0.4 + version: 8.0.4 + oxlint: + specifier: ~1.60.0 + version: 1.60.0 + prettier: + specifier: 3.8.3 + version: 3.8.3 + typescript: + specifier: ~6.0.0 + version: 6.0.3 + vite: + specifier: ^8.0.8 + version: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4) + vite-plugin-vue-devtools: + specifier: ^8.1.1 + version: 8.1.1(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3)) + vue-tsc: + specifier: ^3.2.6 + version: 3.2.8(typescript@6.0.3) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@oxlint/binding-android-arm-eabi@1.60.0': + resolution: {integrity: sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.60.0': + resolution: {integrity: sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.60.0': + resolution: {integrity: sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.60.0': + resolution: {integrity: sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.60.0': + resolution: {integrity: sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.60.0': + resolution: {integrity: sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.60.0': + resolution: {integrity: sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.60.0': + resolution: {integrity: sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.60.0': + resolution: {integrity: sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.60.0': + resolution: {integrity: sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.60.0': + resolution: {integrity: sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.60.0': + resolution: {integrity: sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.60.0': + resolution: {integrity: sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.60.0': + resolution: {integrity: sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.60.0': + resolution: {integrity: sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.60.0': + resolution: {integrity: sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.60.0': + resolution: {integrity: sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.60.0': + resolution: {integrity: sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.60.0': + resolution: {integrity: sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.13': + resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@tsconfig/node24@24.0.4': + resolution: {integrity: sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-vue@6.0.6': + resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue-macros/common@3.1.2': + resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/babel-helper-vue-transform-on@1.5.0': + resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==} + + '@vue/babel-plugin-jsx@1.5.0': + resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@vue/babel-plugin-resolve-type@1.5.0': + resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-api@8.1.1': + resolution: {integrity: sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==} + + '@vue/devtools-core@8.1.1': + resolution: {integrity: sha512-bCCsSABp1/ot4j8xJEycM6Mtt2wbuucfByr6hMgjbYhrtlscOJypZKvy8f1FyWLYrLTchB5Qz216Lm92wfbq0A==} + peerDependencies: + vue: ^3.0.0 + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-kit@8.1.1': + resolution: {integrity: sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/devtools-shared@8.1.1': + resolution: {integrity: sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==} + + '@vue/eslint-config-typescript@14.7.0': + resolution: {integrity: sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@3.2.8': + resolution: {integrity: sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g==} + + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + peerDependencies: + vue: 3.5.34 + + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + + '@vue/tsconfig@0.9.1': + resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==} + peerDependencies: + typescript: '>= 5.8' + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + alien-signals@3.1.2: + resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.8.3: + resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} + engines: {node: '>=20.19.0'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + engines: {node: '>=6.0.0'} + hasBin: true + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.351: + resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-oxlint@1.60.0: + resolution: {integrity: sha512-9RUD23k7ablez1qg7JWnyPYPOlbucDDqaDr+qNUi0TbIQCPqIPCLzfllgqKF9lOxlg+l17H8hISErmarvm2J1w==} + peerDependencies: + oxlint: ~1.60.0 + + eslint-plugin-vue-scoped-css@3.1.0: + resolution: {integrity: sha512-R9XLrIZaP6QGz9b4kO2K4+lP4NcO2TKcw71zBtIYCoqqTk5ja1ySruYAllBT2LPIJVQ4NZaB2IFSvLjLEpYqQA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + postcss-scss: ^4.0.3 + postcss-styl: ^0.12.0 + vue-eslint-parser: '>=7.1.0' + peerDependenciesMeta: + postcss-scss: + optional: true + postcss-styl: + optional: true + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.3.0: + resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@4.0.0: + resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==} + engines: {node: ^18.17.0 || >=20.5.0} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + npm-normalize-package-bin@4.0.0: + resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-run-all2@8.0.4: + resolution: {integrity: sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==} + engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + oxlint@1.60.0: + resolution: {integrity: sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.18.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-package-json-fast@4.0.0: + resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} + engines: {node: ^18.17.0 || >=20.5.0} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.2: + resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-dev-rpc@1.1.0: + resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0 + + vite-hot-client@2.2.0: + resolution: {integrity: sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==} + peerDependencies: + vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0 + + vite-plugin-inspect@11.3.3: + resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + vite-plugin-vue-devtools@8.1.1: + resolution: {integrity: sha512-9qTpOmZ2vHpvlI9hdVXAQ1Ry4I8GcBArU7aPi0qfIaV7fQIXy0L1nb6X4mFY2Gw0dYshHuLbIl0Ulb572SCjsQ==} + engines: {node: '>=v14.21.3'} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite-plugin-vue-inspector@5.4.0: + resolution: {integrity: sha512-Iq/024CydcE46FZqWPU4t4lw4uYOdLnFSO1RNxJVt2qY9zxIjmnkBqhHnYaReWM82kmNnaXs7OkfgRrV2GEjyw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@5.0.6: + resolution: {integrity: sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.17 + pinia: ^3.0.4 + vue: ^3.5.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + + vue-tsc@3.2.8: + resolution: {integrity: sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@2.7.0))': + dependencies: + eslint: 10.3.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/types@0.127.0': {} + + '@oxlint/binding-android-arm-eabi@1.60.0': + optional: true + + '@oxlint/binding-android-arm64@1.60.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.60.0': + optional: true + + '@oxlint/binding-darwin-x64@1.60.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.60.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.60.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.60.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.60.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.60.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.60.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.60.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.60.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.60.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.60.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.60.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.60.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.60.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.60.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.60.0': + optional: true + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.13': {} + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@tsconfig/node24@24.0.4': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 10.3.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + eslint: 10.3.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.3.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + eslint: 10.3.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.13 + vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4) + vue: 3.5.34(typescript@6.0.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-macros/common@3.1.2(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue/compiler-sfc': 3.5.34 + ast-kit: 2.2.0 + local-pkg: 1.1.2 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.1 + optionalDependencies: + vue: 3.5.34(typescript@6.0.3) + + '@vue/babel-helper-vue-transform-on@1.5.0': {} + + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.0)': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@vue/babel-helper-vue-transform-on': 1.5.0 + '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.0) + '@vue/shared': 3.5.34 + optionalDependencies: + '@babel/core': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.0)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/parser': 7.29.3 + '@vue/compiler-sfc': 3.5.34 + transitivePeerDependencies: + - supports-color + + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.14 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-api@8.1.1': + dependencies: + '@vue/devtools-kit': 8.1.1 + + '@vue/devtools-core@8.1.1(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue/devtools-kit': 8.1.1 + '@vue/devtools-shared': 8.1.1 + vue: 3.5.34(typescript@6.0.3) + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-kit@8.1.1': + dependencies: + '@vue/devtools-shared': 8.1.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/devtools-shared@8.1.1': {} + + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.3.0(jiti@2.7.0) + eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))) + fast-glob: 3.3.3 + typescript-eslint: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + vue-eslint-parser: 10.4.0(eslint@10.3.0(jiti@2.7.0)) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@3.2.8': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + alien-signals: 3.1.2 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.4 + + '@vue/reactivity@3.5.34': + dependencies: + '@vue/shared': 3.5.34 + + '@vue/runtime-core@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/runtime-dom@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 + '@vue/shared': 3.5.34 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + vue: 3.5.34(typescript@6.0.3) + + '@vue/shared@3.5.34': {} + + '@vue/tsconfig@0.9.1(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))': + optionalDependencies: + typescript: 6.0.3 + vue: 3.5.34(typescript@6.0.3) + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@3.1.2: {} + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.3 + pathe: 2.0.3 + + ast-walker-scope@0.8.3: + dependencies: + '@babel/parser': 7.29.3 + ast-kit: 2.2.0 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.27: {} + + birpc@2.9.0: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.351 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + caniuse-lite@1.0.30001792: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + convert-source-map@2.0.0: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.351: {} + + entities@7.0.1: {} + + error-stack-parser-es@1.0.5: {} + + es-toolkit@1.46.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.3.0(jiti@2.7.0)): + dependencies: + eslint: 10.3.0(jiti@2.7.0) + + eslint-plugin-oxlint@1.60.0(oxlint@1.60.0): + dependencies: + jsonc-parser: 3.3.1 + oxlint: 1.60.0 + + eslint-plugin-vue-scoped-css@3.1.0(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + es-toolkit: 1.46.1 + eslint: 10.3.0(jiti@2.7.0) + postcss: 8.5.14 + postcss-safe-parser: 7.0.1(postcss@8.5.14) + postcss-selector-parser: 7.1.1 + vue-eslint-parser: 10.4.0(eslint@10.3.0(jiti@2.7.0)) + + eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + eslint: 10.3.0(jiti@2.7.0) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.7.4 + vue-eslint-parser: 10.4.0(eslint@10.3.0(jiti@2.7.0)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.3.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + exsolve@1.0.8: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + hookable@5.5.3: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-what@5.5.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@4.0.0: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + memorystream@0.3.1: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.38: {} + + npm-normalize-package-bin@4.0.0: {} + + npm-run-all2@8.0.4: + dependencies: + ansi-styles: 6.2.3 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + picomatch: 4.0.4 + pidtree: 0.6.0 + read-package-json-fast: 4.0.0 + shell-quote: 1.8.3 + which: 5.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + ohash@2.0.11: {} + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + oxlint@1.60.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.60.0 + '@oxlint/binding-android-arm64': 1.60.0 + '@oxlint/binding-darwin-arm64': 1.60.0 + '@oxlint/binding-darwin-x64': 1.60.0 + '@oxlint/binding-freebsd-x64': 1.60.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.60.0 + '@oxlint/binding-linux-arm-musleabihf': 1.60.0 + '@oxlint/binding-linux-arm64-gnu': 1.60.0 + '@oxlint/binding-linux-arm64-musl': 1.60.0 + '@oxlint/binding-linux-ppc64-gnu': 1.60.0 + '@oxlint/binding-linux-riscv64-gnu': 1.60.0 + '@oxlint/binding-linux-riscv64-musl': 1.60.0 + '@oxlint/binding-linux-s390x-gnu': 1.60.0 + '@oxlint/binding-linux-x64-gnu': 1.60.0 + '@oxlint/binding-linux-x64-musl': 1.60.0 + '@oxlint/binding-openharmony-arm64': 1.60.0 + '@oxlint/binding-win32-arm64-msvc': 1.60.0 + '@oxlint/binding-win32-ia32-msvc': 1.60.0 + '@oxlint/binding-win32-x64-msvc': 1.60.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pidtree@0.6.0: {} + + pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + vue: 3.5.34(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss-safe-parser@7.0.1(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + read-package-json-fast@4.0.0: + dependencies: + json-parse-even-better-errors: 4.0.0 + npm-normalize-package-bin: 4.0.0 + + readdirp@5.0.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scule@1.3.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.3.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + undici-types@7.16.0: {} + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-dev-rpc@1.1.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)): + dependencies: + birpc: 2.9.0 + vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4) + vite-hot-client: 2.2.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)) + + vite-hot-client@2.2.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)): + dependencies: + vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4) + + vite-plugin-inspect@11.3.3(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)): + dependencies: + ansis: 4.2.0 + debug: 4.4.3 + error-stack-parser-es: 1.0.5 + ohash: 2.0.11 + open: 10.2.0 + perfect-debounce: 2.1.0 + sirv: 3.0.2 + unplugin-utils: 0.3.1 + vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4) + vite-dev-rpc: 1.1.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)) + transitivePeerDependencies: + - supports-color + + vite-plugin-vue-devtools@8.1.1(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3)): + dependencies: + '@vue/devtools-core': 8.1.1(vue@3.5.34(typescript@6.0.3)) + '@vue/devtools-kit': 8.1.1 + '@vue/devtools-shared': 8.1.1 + sirv: 3.0.2 + vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4) + vite-plugin-inspect: 11.3.3(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)) + vite-plugin-vue-inspector: 5.4.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)) + transitivePeerDependencies: + - '@nuxt/kit' + - supports-color + - vue + + vite-plugin-vue-inspector@5.4.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0) + '@vue/compiler-dom': 3.5.34 + kolorist: 1.8.0 + magic-string: 0.30.21 + vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4) + transitivePeerDependencies: + - supports-color + + vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.8.4 + + vscode-uri@3.1.0: {} + + vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 10.3.0(jiti@2.7.0) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + vue-router@5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)): + dependencies: + '@babel/generator': 7.29.1 + '@vue-macros/common': 3.1.2(vue@3.5.34(typescript@6.0.3)) + '@vue/devtools-api': 8.1.1 + ast-walker-scope: 0.8.3 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.4 + scule: 1.3.0 + tinyglobby: 0.2.16 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + vue: 3.5.34(typescript@6.0.3) + yaml: 2.8.4 + optionalDependencies: + '@vue/compiler-sfc': 3.5.34 + pinia: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + + vue-tsc@3.2.8(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.2.8 + typescript: 6.0.3 + + vue@3.5.34(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) + '@vue/shared': 3.5.34 + optionalDependencies: + typescript: 6.0.3 + + webpack-virtual-modules@0.6.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.5 + + word-wrap@1.2.5: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xml-name-validator@4.0.0: {} + + yallist@3.1.1: {} + + yaml@2.8.4: {} + + yocto-queue@0.1.0: {} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8c43063 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..db72f1a --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,83 @@ +const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '') +const API_PREFIX = '/v1' + +let accessToken: string | null = null + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public details?: unknown, + ) { + super(message) + this.name = 'ApiError' + } +} + +export function setApiAccessToken(token: string | null) { + accessToken = token +} + +export function getApiAccessToken() { + return accessToken +} + +function makeUrl(path: string, query?: Record) { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const url = new URL(`${API_BASE_URL}${API_PREFIX}${normalizedPath}`, window.location.origin) + + Object.entries(query ?? {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return + url.searchParams.set(key, String(value)) + }) + + return url.toString() +} + +export function buildApiUrl(path: string, query?: Record) { + return makeUrl(path, query) +} + +async function parseResponse(response: Response) { + if (response.status === 204) return undefined + + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) return response.json() + + const text = await response.text() + return text || undefined +} + +export async function apiRequest( + path: string, + options: RequestInit & { query?: Record } = {}, +): Promise { + const headers = new Headers(options.headers) + if (!headers.has('Accept')) headers.set('Accept', 'application/json') + if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json') + if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`) + + const response = await fetch(makeUrl(path, options.query), { + ...options, + headers, + credentials: 'include', + }) + const body = await parseResponse(response) + + if (!response.ok) { + const message = + typeof body === 'object' && body && 'message' in body + ? String((body as { message: unknown }).message) + : typeof body === 'object' && body && 'detail' in body + ? String((body as { detail: unknown }).detail) + : `API request failed with status ${response.status}` + throw new ApiError(message, response.status, body) + } + + return body as T +} + +export function extractItems(payload: T[] | { items?: T[] } | undefined): T[] { + if (Array.isArray(payload)) return payload + return payload?.items ?? [] +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..6ef26b3 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,226 @@ +import { apiRequest, extractItems } from './client' +import type { + AchievementDto, + AuthResponse, + CoinTransactionDto, + CourseDto, + CreateLectureRequest, + LectureDto, + LectureQuery, + LocationDto, + PagedResult, + ReviewDto, + ReviewQuery, + ReviewPromptDto, + SyncResultDto, + SyncScheduleRequest, + SyncStatusDto, + TagDto, + UpdateReviewPromptRequest, + UserAchievementDto, + CurrentUserDto, + UserDto, + UserQuery, + UserNotificationDto, + UserStatsDto, +} from './types' + +export const authApi = { + loginMicrosoft: (authorizationCode: string, redirectUri?: string) => + apiRequest('/auth/login/microsoft', { + method: 'POST', + body: JSON.stringify({ authorizationCode, redirectUri }), + }), + refresh: () => apiRequest('/auth/refresh', { method: 'POST' }), + logout: () => apiRequest('/auth/logout', { method: 'POST' }), + me: () => apiRequest('/auth/me'), +} + +export const lecturesApi = { + async list(query: LectureQuery = {}) { + const payload = await apiRequest | LectureDto[]>('/lectures', { + query: query as Record, + }) + return extractItems(payload) + }, + get: (id: string | number) => apiRequest(`/lectures/${id}`), + create: (payload: CreateLectureRequest) => + apiRequest('/lectures', { + method: 'POST', + body: JSON.stringify(payload), + }), + enroll: (id: string | number) => apiRequest(`/lectures/${id}/enroll`, { method: 'POST' }), + unenroll: (id: string | number) => + apiRequest(`/lectures/${id}/enroll`, { method: 'DELETE' }), + async reviews(id: string | number) { + const payload = await apiRequest | ReviewDto[]>( + `/lectures/${id}/reviews`, + ) + return extractItems(payload) + }, +} + +export const usersApi = { + me: () => apiRequest('/users/me'), + updateMe: (payload: { displayName?: string | null; avatarUrl?: string | null }) => + apiRequest('/users/me', { + method: 'PUT', + body: JSON.stringify(payload), + }), + myStats: () => apiRequest('/users/me/stats'), + async myEnrollments() { + const payload = await apiRequest | LectureDto[] | undefined>( + '/users/me/enrollments', + ) + return extractItems(payload) + }, + async myAchievements() { + const payload = await apiRequest< + PagedResult | UserAchievementDto[] | AchievementDto[] + >('/users/me/achievements') + if (Array.isArray(payload)) return payload + return payload.items ?? [] + }, + async myTransactions() { + const payload = await apiRequest | CoinTransactionDto[]>( + '/users/me/transactions', + ) + return extractItems(payload) + }, + get: (id: string | number) => apiRequest(`/users/${id}`), + async list(query: UserQuery = {}) { + const payload = await apiRequest | UserDto[]>('/users', { + query: query as Record, + }) + return extractItems(payload) + }, + stats: (id: string | number) => apiRequest(`/users/${id}/stats`), + async enrollments(id: string | number) { + const payload = await apiRequest | LectureDto[] | undefined>( + `/users/${id}/enrollments`, + ) + return extractItems(payload) + }, + async achievements(id: string | number) { + const payload = await apiRequest< + PagedResult | UserAchievementDto[] | AchievementDto[] + >(`/users/${id}/achievements`) + if (Array.isArray(payload)) return payload + return payload.items ?? [] + }, + async transactions(id: string | number) { + const payload = await apiRequest | CoinTransactionDto[]>( + `/users/${id}/transactions`, + ) + return extractItems(payload) + }, + setRole: (id: string | number, roles: Array<'Student' | 'Teacher' | 'Admin'>) => + apiRequest(`/users/${id}/role`, { + method: 'PATCH', + body: JSON.stringify(roles), + }), + setActive: (id: string | number, isActive: boolean) => + apiRequest(`/users/${id}/active`, { + method: 'PATCH', + body: JSON.stringify(isActive), + }), +} + +export const achievementsApi = { + async list() { + const payload = await apiRequest | AchievementDto[]>( + '/achievements', + ) + return extractItems(payload) + }, +} + +export const notificationsApi = { + async list() { + const payload = await apiRequest | UserNotificationDto[]>( + '/notifications', + ) + return extractItems(payload) + }, + markAllRead: () => apiRequest('/notifications/read-all', { method: 'PATCH' }), +} + +function normalizePagedResult( + payload: PagedResult | T[] | undefined, + query: { Page?: number; PageSize?: number } = {}, +): PagedResult { + if (!Array.isArray(payload) && payload) return payload + + const items = payload ?? [] + const page = query.Page ?? 1 + const pageSize = query.PageSize ?? items.length + const totalPages = pageSize > 0 ? Math.ceil(items.length / pageSize) : 0 + + return { + items, + totalCount: items.length, + page, + pageSize, + totalPages, + } +} + +async function listReviewsPage(query: ReviewQuery = {}) { + const payload = await apiRequest | ReviewDto[]>('/reviews', { + query: query as Record, + }) + return normalizePagedResult(payload, query) +} + +export const reviewsApi = { + create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) => + apiRequest('/reviews', { + method: 'POST', + body: JSON.stringify({ lectureId: Number(lectureId), rating, text }), + }), + getPrompt: () => apiRequest('/reviews/llm-prompt'), + updatePrompt: (payload: UpdateReviewPromptRequest) => + apiRequest('/reviews/llm-prompt', { + method: 'PUT', + body: JSON.stringify(payload), + }), + listPage: listReviewsPage, + async list(query: ReviewQuery = { PageSize: 100 }) { + return (await listReviewsPage(query)).items + }, + reanalyze: (id: string | number) => + apiRequest(`/reviews/${id}/reanalyze`, { method: 'POST' }), +} + +export const coursesApi = { + async list() { + const payload = await apiRequest | CourseDto[]>('/courses', { + query: { PageSize: 100 }, + }) + return extractItems(payload) + }, +} + +export const locationsApi = { + async list() { + const payload = await apiRequest | LocationDto[]>('/locations') + return extractItems(payload) + }, +} + +export const tagsApi = { + async list() { + const payload = await apiRequest | TagDto[]>('/tags') + return extractItems(payload) + }, +} + +export const syncApi = { + status: () => apiRequest('/sync/status'), + schedule: (request: SyncScheduleRequest) => + apiRequest('/sync/schedule', { + method: 'POST', + body: JSON.stringify(request), + }), + rooms: () => apiRequest('/sync/rooms', { method: 'POST' }), +} diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts new file mode 100644 index 0000000..97a093b --- /dev/null +++ b/frontend/src/api/mappers.ts @@ -0,0 +1,173 @@ +import type { + Achievement, + CoinTransaction, + Lecture, + Notification, + Review, + User, + UserRole, +} from '@/types' +import type { + AchievementDto, + CoinTransactionDto, + LectureDto, + ReviewDto, + CurrentUserDto, + UserAuthDto, + UserDto, + UserStatsDto, + UserAchievementDto, + UserNotificationDto, +} from './types' + +export function mapApiRole(role: string | undefined): UserRole { + if (role === 'Teacher') return 'teacher' + if (role === 'Admin') return 'admin' + return 'student' +} + +function mapApiRoles(roles: string[] | undefined): UserRole[] { + if (!roles?.length) return ['student'] + return Array.from(new Set(roles.map(mapApiRole))) +} + +function getDefaultActiveRole(roles: UserRole[]): UserRole { + if (roles.includes('admin')) return 'admin' + if (roles.includes('teacher')) return 'teacher' + if (roles.includes('student')) return 'student' + return 'student' +} + +export function mapApiUser( + user: UserAuthDto | UserDto | CurrentUserDto, + stats?: UserStatsDto, +): User { + const roles = mapApiRoles(user.roles) + return { + id: user.id, + name: user.displayName || user.email || 'Пользователь UniVerse', + email: user.email || '', + roles, + activeRole: getDefaultActiveRole(roles), + avatar: 'avatarUrl' in user ? (user.avatarUrl ?? undefined) : undefined, + institute: 'ЮФУ', + department: '', + year: 0, + direction: '', + coins: stats?.coins ?? ('coins' in user ? user.coins : 0), + level: stats?.level ?? ('level' in user ? user.level : 1), + xp: stats?.xp ?? ('xp' in user ? user.xp : 0), + currentLevelXp: stats?.currentLevelXp ?? 0, + nextLevelXp: stats?.nextLevelXp, + lecturesAttended: stats?.attendedLectures ?? 0, + hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0, + achievements: stats + ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) + : [], + activeEnrollments: stats?.activeEnrollments, + enrollmentSlotLimit: stats?.enrollmentSlotLimit, + enrollmentSlotRules: stats?.enrollmentSlotRules, + } +} + +export function mapApiLecture(lecture: LectureDto): Lecture { + const startsAt = new Date(lecture.startsAt) + const endsAt = new Date(lecture.endsAt) + const durationMs = endsAt.getTime() - startsAt.getTime() + const duration = + Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs / 60000) : 90 + const totalSeats = lecture.maxEnrollments || 0 + const enrolled = lecture.enrollmentsCount || 0 + const freeSeats = Math.max(totalSeats - enrolled, 0) + const locationName = + lecture.locationName || (lecture.format === 'Online' ? 'Онлайн' : 'Аудитория уточняется') + + return { + id: String(lecture.id), + teacherId: lecture.teacherId, + title: lecture.title || lecture.courseName || 'Лекция без названия', + description: lecture.description || 'Описание появится позже.', + teacher: lecture.teacherName || 'Преподаватель уточняется', + teacherTitle: '', + department: '', + institute: lecture.courseName || 'ЮФУ', + date: startsAt.toISOString().slice(0, 10), + time: startsAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }), + duration, + building: lecture.format === 'Online' ? 'Онлайн' : locationName, + room: undefined, + format: lecture.format === 'Online' ? 'online' : 'offline', + totalSeats, + enrolledSeats: enrolled, + freeSeats, + registrationClosed: !lecture.isOpen, + tags: lecture.courseName ? [`#${lecture.courseName}`] : [], + rating: 0, + reviewCount: 0, + status: startsAt.getTime() > Date.now() ? 'upcoming' : 'completed', + registered: lecture.isEnrolled, + } +} + +export function mapApiReview(review: ReviewDto): Review { + const sentiment = + review.sentiment === 'Positive' + ? 'positive' + : review.sentiment === 'Negative' + ? 'negative' + : 'neutral' + const status = + review.llmStatus === 'Rejected' + ? 'rejected' + : review.llmStatus === 'Analyzed' + ? 'done' + : 'pending' + + return { + id: String(review.id), + lectureId: String(review.lectureId), + userId: String(review.userId), + userName: review.userName || 'Анонимный отзыв', + text: review.text || '', + sentiment, + createdAt: review.createdAt, + status, + quality: review.qualityScore ?? undefined, + } +} + +export function mapApiAchievement(input: AchievementDto | UserAchievementDto): Achievement { + const dto = 'achievement' in input ? input.achievement : input + const awardedAt = 'achievement' in input ? input.awardedAt : undefined + + return { + id: String(dto.id), + title: dto.name || 'Достижение', + description: dto.description || dto.condition || '', + icon: dto.iconUrl || '⭐', + unlocked: Boolean(awardedAt), + unlockedAt: awardedAt, + coins: dto.coinReward, + } +} + +export function mapApiCoinTransaction(transaction: CoinTransactionDto): CoinTransaction { + return { + id: String(transaction.id), + date: transaction.createdAt.slice(0, 10), + description: transaction.description || transaction.type, + amount: transaction.amount, + type: transaction.amount >= 0 ? 'earned' : 'spent', + } +} + +export function mapApiNotification(notification: UserNotificationDto): Notification { + return { + id: String(notification.id), + type: notification.type, + title: notification.title, + body: notification.body, + read: notification.isRead, + createdAt: notification.createdAt, + } +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..cf6474d --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,265 @@ +export type ApiUserRole = 'Student' | 'Teacher' | 'Admin' +export type ApiLectureFormat = 'Online' | 'Offline' +export type ApiReviewRating = 'Like' | 'Neutral' | 'Dislike' +export type ApiReviewLlmStatus = 'Pending' | 'Analyzed' | 'Rejected' +export type ApiReviewSentiment = 'Positive' | 'Neutral' | 'Negative' +export type ApiCoinTransactionType = + | 'ReviewReward' + | 'AchievementReward' + | 'AttendanceReward' + | 'AdminAdjustment' + +export interface PagedResult { + items: T[] + totalCount: number + page: number + pageSize: number + totalPages: number +} + +export interface AuthResponse { + accessToken: string + expiresAt: string + user: UserAuthDto +} + +export interface LoginMicrosoftRequest { + authorizationCode: string + redirectUri?: string +} + +export interface UserAuthDto { + id: number + email: string + displayName?: string | null + roles: ApiUserRole[] +} + +export interface UserDto extends UserAuthDto { + id: number + avatarUrl?: string | null + isActive: boolean + xp: number + coins: number + level: number + createdAt: string +} + +export interface CurrentUserDto extends UserAuthDto { + avatarUrl?: string | null + xp: number + coins: number + level: number + createdAt: string +} + +export interface UserQuery { + Search?: string + Role?: ApiUserRole + IsActive?: boolean + Page?: number + PageSize?: number +} + +export interface UserStatsDto { + totalLectures: number + attendedLectures: number + totalReviews: number + xp: number + coins: number + level: number + achievementsCount: number + currentLevelXp: number + nextLevelXp?: number | null + activeEnrollments: number + enrollmentSlotLimit: number + enrollmentSlotRules: EnrollmentSlotRuleDto[] +} + +export interface EnrollmentSlotRuleDto { + level: number + slots: number +} + +export interface LectureDto { + id: number + courseId: number + courseName?: string | null + teacherId?: number | null + teacherName?: string | null + locationId?: number | null + locationName?: string | null + title?: string | null + description?: string | null + format: ApiLectureFormat + startsAt: string + endsAt: string + isOpen: boolean + maxEnrollments: number + enrollmentsCount: number + onlineUrl?: string | null + createdAt: string + isEnrolled?: boolean +} + +export interface CreateLectureRequest { + courseId: number + teacherId?: number | null + locationId?: number | null + title: string + description?: string | null + format: ApiLectureFormat + startsAt: string + endsAt: string + isOpen: boolean + maxEnrollments: number + onlineUrl?: string | null +} + +export interface ReviewQuery { + LlmStatus?: ApiReviewLlmStatus + Page?: number + PageSize?: number +} + +export interface ReviewDto { + id: number + lectureId: number + lectureTitle?: string | null + userId: number + userName?: string | null + rating: ApiReviewRating + text?: string | null + llmStatus: ApiReviewLlmStatus + sentiment?: ApiReviewSentiment | null + qualityScore?: number | null + isInformative?: boolean | null + llmTags?: string[] | null + llmRawOutput?: string | null + createdAt: string +} + +export interface ReviewPromptDto { + prompt: string + updatedAt?: string | null +} + +export interface UpdateReviewPromptRequest { + prompt: string +} + +export interface AchievementDto { + id: number + name?: string | null + description?: string | null + iconUrl?: string | null + xpReward: number + coinReward: number + condition?: string | null + createdAt: string +} + +export interface CourseDto { + id: number + name?: string | null + description?: string | null + isSynced: boolean + tags?: TagDto[] | null + createdAt: string +} + +export interface LocationDto { + id: number + name?: string | null + building?: string | null + room?: string | null + address?: string | null + createdAt: string +} + +export type ApiTagType = 'Institute' | 'Faculty' | 'Subject' | 'Organization' | 'Topic' | 'Other' + +export interface TagDto { + id: number + name?: string | null + type: ApiTagType + parentId?: number | null + createdAt: string +} + +export interface SyncStatusDto { + lastSyncAt?: string | null + status?: string | null + lastResult?: SyncResultDto | null +} + +export type ApiScheduleTypeId = + | 'MID_CHECK' + | 'CONS' + | 'LAB' + | 'LECT' + | 'SEMI' + | 'EVENT_OTHER' + | 'SELF' + | 'CUR_CHECK' + +export interface SyncScheduleRequest { + size: number + timeMin?: string | null + timeMax?: string | null + roomId?: string[] | null + attendeePersonId?: string[] | null + courseUnitRealizationId?: string[] | null + cycleRealizationId?: string[] | null + specialtyCode?: string[] | null + learningStartYear?: number[] | null + profileName?: string[] | null + curriculumId?: string[] | null + typeId?: ApiScheduleTypeId[] | null +} + +export interface SyncResultDto { + created: number + updated: number + skipped: number + error?: string | null + details?: string[] | null +} + +export interface UserAchievementDto { + id: number + achievement: AchievementDto + awardedAt: string +} + +export interface CoinTransactionDto { + id: number + amount: number + type: ApiCoinTransactionType + reviewId?: number | null + achievementId?: number | null + description?: string | null + createdAt: string +} + +export interface UserNotificationDto { + id: number + type: 'reminder' | 'schedule-change' | 'achievement' | 'coins' | 'recommendation' + title: string + body: string + isRead: boolean + createdAt: string +} + +export interface LectureQuery { + DateFrom?: string + DateTo?: string + CourseId?: number + TeacherId?: number + Format?: ApiLectureFormat + IsOpen?: boolean + TagId?: number + Search?: string + Page?: number + PageSize?: number +} diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css new file mode 100644 index 0000000..8816868 --- /dev/null +++ b/frontend/src/assets/base.css @@ -0,0 +1,86 @@ +/* color palette from */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/assets/icons/alarm.svg b/frontend/src/assets/icons/alarm.svg new file mode 100644 index 0000000..89f98f8 --- /dev/null +++ b/frontend/src/assets/icons/alarm.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/src/assets/icons/alert-triangle.svg b/frontend/src/assets/icons/alert-triangle.svg new file mode 100644 index 0000000..8d9332e --- /dev/null +++ b/frontend/src/assets/icons/alert-triangle.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/bell.svg b/frontend/src/assets/icons/bell.svg new file mode 100644 index 0000000..fdf276b --- /dev/null +++ b/frontend/src/assets/icons/bell.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/book-2.svg b/frontend/src/assets/icons/book-2.svg new file mode 100644 index 0000000..d3464f1 --- /dev/null +++ b/frontend/src/assets/icons/book-2.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/books.svg b/frontend/src/assets/icons/books.svg new file mode 100644 index 0000000..13f40f8 --- /dev/null +++ b/frontend/src/assets/icons/books.svg @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/frontend/src/assets/icons/building.svg b/frontend/src/assets/icons/building.svg new file mode 100644 index 0000000..7f85a63 --- /dev/null +++ b/frontend/src/assets/icons/building.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/frontend/src/assets/icons/bulb.svg b/frontend/src/assets/icons/bulb.svg new file mode 100644 index 0000000..b6577b3 --- /dev/null +++ b/frontend/src/assets/icons/bulb.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/calendar-event.svg b/frontend/src/assets/icons/calendar-event.svg new file mode 100644 index 0000000..d3b85c2 --- /dev/null +++ b/frontend/src/assets/icons/calendar-event.svg @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/frontend/src/assets/icons/calendar.svg b/frontend/src/assets/icons/calendar.svg new file mode 100644 index 0000000..20d6753 --- /dev/null +++ b/frontend/src/assets/icons/calendar.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/frontend/src/assets/icons/chart-bar.svg b/frontend/src/assets/icons/chart-bar.svg new file mode 100644 index 0000000..0e55695 --- /dev/null +++ b/frontend/src/assets/icons/chart-bar.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/src/assets/icons/chart-line.svg b/frontend/src/assets/icons/chart-line.svg new file mode 100644 index 0000000..97a4c02 --- /dev/null +++ b/frontend/src/assets/icons/chart-line.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/circle-check.svg b/frontend/src/assets/icons/circle-check.svg new file mode 100644 index 0000000..73eb6ae --- /dev/null +++ b/frontend/src/assets/icons/circle-check.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/circle-x.svg b/frontend/src/assets/icons/circle-x.svg new file mode 100644 index 0000000..c0bba84 --- /dev/null +++ b/frontend/src/assets/icons/circle-x.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/clipboard-list.svg b/frontend/src/assets/icons/clipboard-list.svg new file mode 100644 index 0000000..654a4c5 --- /dev/null +++ b/frontend/src/assets/icons/clipboard-list.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/frontend/src/assets/icons/clock.svg b/frontend/src/assets/icons/clock.svg new file mode 100644 index 0000000..e4814fa --- /dev/null +++ b/frontend/src/assets/icons/clock.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/coin.svg b/frontend/src/assets/icons/coin.svg new file mode 100644 index 0000000..60eb83b --- /dev/null +++ b/frontend/src/assets/icons/coin.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/hand-stop.svg b/frontend/src/assets/icons/hand-stop.svg new file mode 100644 index 0000000..2c46e10 --- /dev/null +++ b/frontend/src/assets/icons/hand-stop.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/src/assets/icons/home.svg b/frontend/src/assets/icons/home.svg new file mode 100644 index 0000000..d5fa9e3 --- /dev/null +++ b/frontend/src/assets/icons/home.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/inbox.svg b/frontend/src/assets/icons/inbox.svg new file mode 100644 index 0000000..9356470 --- /dev/null +++ b/frontend/src/assets/icons/inbox.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/info-circle.svg b/frontend/src/assets/icons/info-circle.svg new file mode 100644 index 0000000..c035ec3 --- /dev/null +++ b/frontend/src/assets/icons/info-circle.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/lock.svg b/frontend/src/assets/icons/lock.svg new file mode 100644 index 0000000..984acce --- /dev/null +++ b/frontend/src/assets/icons/lock.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/logout.svg b/frontend/src/assets/icons/logout.svg new file mode 100644 index 0000000..50b5972 --- /dev/null +++ b/frontend/src/assets/icons/logout.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/map-pin.svg b/frontend/src/assets/icons/map-pin.svg new file mode 100644 index 0000000..7501d8f --- /dev/null +++ b/frontend/src/assets/icons/map-pin.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/message-circle.svg b/frontend/src/assets/icons/message-circle.svg new file mode 100644 index 0000000..e712c74 --- /dev/null +++ b/frontend/src/assets/icons/message-circle.svg @@ -0,0 +1,19 @@ + + + + diff --git a/frontend/src/assets/icons/mood-neutral.svg b/frontend/src/assets/icons/mood-neutral.svg new file mode 100644 index 0000000..58cf5b8 --- /dev/null +++ b/frontend/src/assets/icons/mood-neutral.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/frontend/src/assets/icons/robot.svg b/frontend/src/assets/icons/robot.svg new file mode 100644 index 0000000..cdbb7b7 --- /dev/null +++ b/frontend/src/assets/icons/robot.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/icons/search.svg b/frontend/src/assets/icons/search.svg new file mode 100644 index 0000000..3498a25 --- /dev/null +++ b/frontend/src/assets/icons/search.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/shield.svg b/frontend/src/assets/icons/shield.svg new file mode 100644 index 0000000..071513a --- /dev/null +++ b/frontend/src/assets/icons/shield.svg @@ -0,0 +1,19 @@ + + + + diff --git a/frontend/src/assets/icons/sparkles.svg b/frontend/src/assets/icons/sparkles.svg new file mode 100644 index 0000000..16d2cab --- /dev/null +++ b/frontend/src/assets/icons/sparkles.svg @@ -0,0 +1,19 @@ + + + + diff --git a/frontend/src/assets/icons/star.svg b/frontend/src/assets/icons/star.svg new file mode 100644 index 0000000..38eecdb --- /dev/null +++ b/frontend/src/assets/icons/star.svg @@ -0,0 +1,19 @@ + + + + diff --git a/frontend/src/assets/icons/stopwatch.svg b/frontend/src/assets/icons/stopwatch.svg new file mode 100644 index 0000000..9d85b90 --- /dev/null +++ b/frontend/src/assets/icons/stopwatch.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/src/assets/icons/thumb-down.svg b/frontend/src/assets/icons/thumb-down.svg new file mode 100644 index 0000000..01a987d --- /dev/null +++ b/frontend/src/assets/icons/thumb-down.svg @@ -0,0 +1,19 @@ + + + + diff --git a/frontend/src/assets/icons/thumb-up.svg b/frontend/src/assets/icons/thumb-up.svg new file mode 100644 index 0000000..823b4eb --- /dev/null +++ b/frontend/src/assets/icons/thumb-up.svg @@ -0,0 +1,19 @@ + + + + diff --git a/frontend/src/assets/icons/trophy.svg b/frontend/src/assets/icons/trophy.svg new file mode 100644 index 0000000..6b7d40e --- /dev/null +++ b/frontend/src/assets/icons/trophy.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/frontend/src/assets/icons/user.svg b/frontend/src/assets/icons/user.svg new file mode 100644 index 0000000..52fc7ea --- /dev/null +++ b/frontend/src/assets/icons/user.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/assets/icons/users.svg b/frontend/src/assets/icons/users.svg new file mode 100644 index 0000000..1eca1af --- /dev/null +++ b/frontend/src/assets/icons/users.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/src/assets/icons/world.svg b/frontend/src/assets/icons/world.svg new file mode 100644 index 0000000..9803590 --- /dev/null +++ b/frontend/src/assets/icons/world.svg @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/frontend/src/assets/images/login-campus.webp b/frontend/src/assets/images/login-campus.webp new file mode 100644 index 0000000..dc2ae70 Binary files /dev/null and b/frontend/src/assets/images/login-campus.webp differ diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..cce9735 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,621 @@ +:root { + --color-white: #ffffff; + --color-black: #000000; + + --color-primary: #22c55e; + --color-primary-dark: #16a34a; + --color-primary-light: #86efac; + --color-primary-bright: #4ade80; + --color-primary-border: #15803d; + + --color-aqua: #06b6d4; + --color-aqua-dark: #0e7490; + --color-aqua-light: #67e8f9; + + --color-sky: #7dd3fc; + --color-sky-light: #bae6fd; + + --color-white-glass: rgba(255, 255, 255, 0.75); + --color-surface: rgba(255, 255, 255, 0.85); + --color-border-glass: rgba(255, 255, 255, 0.8); + + --color-text: #1e293b; + --color-text-secondary: #64748b; + + --color-bg-start: #e0f2fe; + --color-bg-mid: #dcfce7; + + --color-error: #ef4444; + --color-error-dark: #dc2626; + --color-error-border: #b91c1c; + + --color-success: #22c55e; + --color-success-text: #166534; + + --color-warning: #f59e0b; + --color-warning-text: #92400e; + --color-warning-border: #fde68a; + + --color-brown-dark: #78350f; + --color-danger-text: #991b1b; + --color-danger-light: #fca5a5; + --color-danger-pale: #fecaca; + --color-info-text: #1e40af; + --color-info-border: #93c5fd; + + --color-orange: #fb923c; + --color-orange-deep: #ea580c; + --color-orange-dark: #c2410c; + --color-yellow: #fcd34d; + + --color-purple: #a78bfa; + --color-purple-light: #c4b5fd; + --color-purple-dark: #6d28d9; + + --color-gray-400: #9ca3af; + --color-star: #fbbf24; + + --color-white-a10: rgba(255, 255, 255, 0.1); + --color-white-a30: rgba(255, 255, 255, 0.3); + --color-white-a40: rgba(255, 255, 255, 0.4); + --color-white-a50: rgba(255, 255, 255, 0.5); + --color-white-a60: rgba(255, 255, 255, 0.6); + --color-white-a70: rgba(255, 255, 255, 0.7); + --color-white-a72: rgba(255, 255, 255, 0.72); + --color-white-a80: rgba(255, 255, 255, 0.8); + --color-white-a82: rgba(255, 255, 255, 0.82); + --color-white-a86: rgba(255, 255, 255, 0.86); + --color-white-a90: rgba(255, 255, 255, 0.9); + --color-white-a96: rgba(255, 255, 255, 0.96); + + --color-black-a04: rgba(0, 0, 0, 0.04); + --color-black-a05: rgba(0, 0, 0, 0.05); + --color-black-a06: rgba(0, 0, 0, 0.06); + --color-black-a08: rgba(0, 0, 0, 0.08); + --color-black-a12: rgba(0, 0, 0, 0.12); + --color-black-a15: rgba(0, 0, 0, 0.15); + --color-black-a20: rgba(0, 0, 0, 0.2); + --color-black-a35: rgba(0, 0, 0, 0.35); + + --color-slate-900-a08: rgba(15, 23, 42, 0.08); + --color-slate-900-a14: rgba(15, 23, 42, 0.14); + --color-slate-500-a10: rgba(100, 116, 139, 0.1); + --color-slate-500-a20: rgba(100, 116, 139, 0.2); + + --color-primary-a05: rgba(34, 197, 94, 0.05); + --color-primary-a08: rgba(34, 197, 94, 0.08); + --color-primary-a10: rgba(34, 197, 94, 0.1); + --color-primary-a12: rgba(34, 197, 94, 0.12); + --color-primary-a15: rgba(34, 197, 94, 0.15); + --color-primary-a18: rgba(34, 197, 94, 0.18); + --color-primary-a20: rgba(34, 197, 94, 0.2); + --color-primary-a25: rgba(34, 197, 94, 0.25); + --color-primary-a30: rgba(34, 197, 94, 0.3); + --color-primary-a40: rgba(34, 197, 94, 0.4); + --color-primary-a45: rgba(34, 197, 94, 0.45); + --color-primary-a50: rgba(34, 197, 94, 0.5); + --color-primary-light-a12: rgba(134, 239, 172, 0.12); + + --color-error-a10: rgba(239, 68, 68, 0.1); + --color-error-a12: rgba(239, 68, 68, 0.12); + --color-error-a20: rgba(239, 68, 68, 0.2); + --color-error-a24: rgba(239, 68, 68, 0.24); + --color-error-a30: rgba(239, 68, 68, 0.3); + --color-error-a40: rgba(239, 68, 68, 0.4); + + --color-aqua-a15: rgba(6, 182, 212, 0.15); + --color-aqua-a25: rgba(6, 182, 212, 0.25); + --color-aqua-a30: rgba(6, 182, 212, 0.3); + --color-aqua-a40: rgba(6, 182, 212, 0.4); + --color-orange-a15: rgba(251, 146, 60, 0.15); + --color-orange-a30: rgba(251, 146, 60, 0.3); + --color-purple-a12: rgba(139, 92, 246, 0.12); + --color-purple-a20: rgba(139, 92, 246, 0.2); + --color-star-a15: rgba(251, 191, 36, 0.15); + --color-star-a20: rgba(251, 191, 36, 0.2); + --color-star-a30: rgba(251, 191, 36, 0.3); + --color-warning-a15: rgba(245, 158, 11, 0.15); + --color-warning-a25: rgba(245, 158, 11, 0.25); + --color-warning-a40: rgba(245, 158, 11, 0.4); + + --color-success-bg-a90: rgba(220, 252, 231, 0.9); + --color-success-bg-a95: rgba(220, 252, 231, 0.95); + --color-info-bg-a90: rgba(224, 242, 254, 0.9); + --color-info-bg-a95: rgba(224, 242, 254, 0.95); + --color-danger-bg-a68: rgba(254, 242, 242, 0.68); + --color-danger-bg-a90: rgba(254, 226, 226, 0.9); + --color-danger-bg-a95: rgba(254, 226, 226, 0.95); + --color-warning-bg-a90: rgba(254, 243, 199, 0.9); + + --gradient-bg: linear-gradient( + 135deg, + var(--color-bg-start) 0%, + var(--color-bg-mid) 50%, + var(--color-bg-start) 100% + ); + --gradient-brand: linear-gradient( + 135deg, + var(--color-primary) 0%, + var(--color-aqua) 60%, + var(--color-sky) 100% + ); + --gradient-progress-success: linear-gradient( + 90deg, + var(--color-primary), + var(--color-primary-light) + ); + --gradient-progress-neutral: linear-gradient(90deg, var(--color-sky), var(--color-sky-light)); + --gradient-progress-danger: linear-gradient( + 90deg, + var(--color-danger-light), + var(--color-danger-pale) + ); + --gradient-bar-success-vertical: linear-gradient( + 180deg, + var(--color-primary), + var(--color-primary-light) + ); + --gradient-bar-neutral-vertical: linear-gradient( + 180deg, + var(--color-sky), + var(--color-sky-light) + ); + --gradient-nav-active: linear-gradient( + 135deg, + var(--color-primary-a18), + var(--color-primary-light-a12) + ); + --gradient-stats-green: linear-gradient(90deg, var(--color-primary), var(--color-primary-light)); + --gradient-stats-aqua: linear-gradient(90deg, var(--color-aqua), var(--color-aqua-light)); + --gradient-stats-orange: linear-gradient(90deg, var(--color-orange), var(--color-yellow)); + --gradient-stats-purple: linear-gradient(90deg, var(--color-purple), var(--color-purple-light)); + --gradient-coin-chip: linear-gradient( + 135deg, + var(--color-primary-a25) 0%, + var(--color-aqua-a25) 100% + ); + --gradient-coin-chip-hover: linear-gradient( + 135deg, + var(--color-primary-a40) 0%, + var(--color-aqua-a40) 100% + ); + --color-coin-chip-border: var(--color-primary-a45); + --color-coin-chip-text: var(--color-primary-border); + --color-coin-chip-label: var(--color-primary-border); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --shadow-glass: 0 8px 32px var(--color-black-a08), inset 0 1px 0 var(--color-white-a90); + --shadow-card: 0 4px 16px var(--color-black-a06); + --sidebar-width: 240px; + --topbar-height: 60px; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + color: var(--color-text); + -webkit-font-smoothing: antialiased; +} + +body { + background: var(--gradient-bg); + background-attachment: fixed; + min-height: 100vh; +} + +a { + color: var(--color-primary-dark); + text-decoration: none; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; +} + +input, +textarea, +select { + font-family: inherit; + outline: none; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--color-black-a04); + border-radius: 3px; +} +::-webkit-scrollbar-thumb { + background: var(--color-primary-a30); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-primary-a50); +} + +/* Glass panel utility */ +.glass-panel { + background: var(--color-white-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-md); + box-shadow: var(--shadow-glass); +} + +/* Primary button */ +.btn-primary { + background: var(--color-primary-a18); + border: 1px solid var(--color-primary-a40); + border-radius: var(--radius-sm); + color: var(--color-primary-border); + padding: 10px 20px; + font-weight: 600; + font-size: 14px; + line-height: 1.2; + cursor: pointer; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + box-shadow: 0 2px 8px var(--color-primary-a08); + backdrop-filter: blur(8px); +} +.btn-primary:hover { + background: var(--color-primary-a25); + border-color: var(--color-primary-a50); + transform: translateY(-1px); + box-shadow: 0 8px 18px var(--color-primary-a15); +} +.btn-primary:focus-visible { + box-shadow: + 0 0 0 3px var(--color-primary-a20), + 0 2px 8px var(--color-primary-a08); +} +.btn-primary:active { + background: var(--color-primary-a30); + transform: translateY(0); + box-shadow: 0 1px 4px var(--color-primary-a12); +} +.btn-primary:disabled { + background: var(--color-slate-500-a10); + border-color: var(--color-slate-500-a20); + color: var(--color-text-secondary); + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* Secondary button */ +.btn-secondary { + background: var(--color-white-a70); + border: 1px solid var(--color-primary-a40); + border-radius: var(--radius-sm); + color: var(--color-primary-dark); + padding: 10px 20px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 6px; + backdrop-filter: blur(8px); +} +.btn-secondary:hover { + background: var(--color-primary-a10); + border-color: var(--color-primary); +} + +/* Danger button */ +.btn-danger { + background: var(--color-danger-bg-a90); + border: 1px solid var(--color-danger-light); + border-radius: var(--radius-sm); + color: var(--color-danger-text); + padding: 10px 20px; + font-weight: 600; + font-size: 14px; + line-height: 1.2; + cursor: pointer; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + box-shadow: 0 2px 8px var(--color-error-a10); + backdrop-filter: blur(8px); +} +.btn-danger:hover { + background: var(--color-danger-bg-a95); + border-color: var(--color-error-a30); + transform: translateY(-1px); + box-shadow: 0 8px 18px var(--color-error-a12); +} +.btn-danger:focus-visible { + box-shadow: + 0 0 0 3px var(--color-error-a20), + 0 2px 8px var(--color-error-a10); +} +.btn-danger:active { + background: var(--color-danger-pale); + transform: translateY(0); + box-shadow: 0 1px 4px var(--color-error-a12); +} +.btn-danger:disabled { + background: var(--color-slate-500-a10); + border-color: var(--color-slate-500-a20); + color: var(--color-text-secondary); + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Ghost button */ +.btn-ghost { + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + padding: 8px 14px; + font-weight: 500; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 6px; +} +.btn-ghost:hover { + background: var(--color-black-a05); + color: var(--color-text); +} + +/* Glass input */ +.glass-input { + background: var(--color-white-a60); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-sm); + padding: 10px 14px; + font-size: 14px; + color: var(--color-text); + width: 100%; + transition: all 0.2s; + backdrop-filter: blur(8px); +} +.glass-input:focus { + background: var(--color-surface); + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-a15); +} +.glass-input::placeholder { + color: var(--color-text-secondary); +} + +/* Badge */ +.badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} +.badge-green { + color: var(--color-primary-border); + border: 1.3px solid var(--color-primary-a30); +} +.badge-blue { + background: var(--color-aqua-a15); + color: var(--color-aqua-dark); + border: 1px solid var(--color-aqua-a30); +} +.badge-orange { + background: var(--color-orange-a15); + color: var(--color-orange-dark); + border: 1px solid var(--color-orange-a30); +} +.badge-gray { + background: var(--color-slate-500-a10); + color: var(--color-text-secondary); + border: 1px solid var(--color-slate-500-a20); +} +.badge-red { + background: var(--color-error-a12); + color: var(--color-error-border); + border: 1px solid var(--color-error-a20); +} +.badge-purple { + background: var(--color-purple-a12); + color: var(--color-purple-dark); + border: 1px solid var(--color-purple-a20); +} + +/* Tag chip */ +.tag-chip { + display: inline-flex; + align-items: center; + padding: 3px 10px; + background: var(--color-primary-a10); + border: 1px solid var(--color-primary-a25); + border-radius: 20px; + font-size: 12px; + color: var(--color-primary-dark); + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.tag-chip:hover, +.tag-chip.active { + background: var(--color-primary-a20); + border-color: var(--color-primary); +} + +/* Page layout helpers */ +.page-content { + padding: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.page-title { + font-size: 22px; + font-weight: 700; + color: var(--color-text); + margin-bottom: 20px; +} + +.section-title { + font-size: 16px; + font-weight: 700; + color: var(--color-text); + margin-bottom: 14px; +} + +/* Grid helpers */ +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} +.grid-4 { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +@media (max-width: 768px) { + .grid-2, + .grid-3, + .grid-4 { + grid-template-columns: 1fr; + } + .page-content { + padding: 16px; + } +} + +/* Flex helpers */ +.flex { + display: flex; +} +.flex-col { + display: flex; + flex-direction: column; +} +.items-center { + align-items: center; +} +.justify-between { + justify-content: space-between; +} +.gap-2 { + gap: 8px; +} +.gap-3 { + gap: 12px; +} +.gap-4 { + gap: 16px; +} + +/* Text helpers */ +.text-sm { + font-size: 12px; +} +.text-secondary { + color: var(--color-text-secondary); +} +.font-bold { + font-weight: 700; +} +.font-semibold { + font-weight: 600; +} + +/* Stars rating */ +.stars { + color: var(--color-star); + font-size: 14px; + letter-spacing: 1px; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.fade-in { + animation: fadeIn 0.3s ease; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.spinner { + width: 20px; + height: 20px; + border: 2px solid var(--color-white-a30); + border-top-color: var(--color-white); + border-radius: 50%; + animation: spin 0.8s linear infinite; + display: inline-block; +} +.spinner-green { + border-color: var(--color-primary-a20); + border-top-color: var(--color-primary); +} + +#app { + min-height: 100vh; +} diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..d174cf8 --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/src/components/TheWelcome.vue b/frontend/src/components/TheWelcome.vue new file mode 100644 index 0000000..8b731d9 --- /dev/null +++ b/frontend/src/components/TheWelcome.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/components/WelcomeItem.vue b/frontend/src/components/WelcomeItem.vue new file mode 100644 index 0000000..6d7086a --- /dev/null +++ b/frontend/src/components/WelcomeItem.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/components/admin/CreateLectureModal.vue b/frontend/src/components/admin/CreateLectureModal.vue new file mode 100644 index 0000000..6cf222b --- /dev/null +++ b/frontend/src/components/admin/CreateLectureModal.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/frontend/src/components/icons/IconCommunity.vue b/frontend/src/components/icons/IconCommunity.vue new file mode 100644 index 0000000..2dc8b05 --- /dev/null +++ b/frontend/src/components/icons/IconCommunity.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconDocumentation.vue b/frontend/src/components/icons/IconDocumentation.vue new file mode 100644 index 0000000..6d4791c --- /dev/null +++ b/frontend/src/components/icons/IconDocumentation.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconEcosystem.vue b/frontend/src/components/icons/IconEcosystem.vue new file mode 100644 index 0000000..c3a4f07 --- /dev/null +++ b/frontend/src/components/icons/IconEcosystem.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconSupport.vue b/frontend/src/components/icons/IconSupport.vue new file mode 100644 index 0000000..7452834 --- /dev/null +++ b/frontend/src/components/icons/IconSupport.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconTooling.vue b/frontend/src/components/icons/IconTooling.vue new file mode 100644 index 0000000..660598d --- /dev/null +++ b/frontend/src/components/icons/IconTooling.vue @@ -0,0 +1,19 @@ + + diff --git a/frontend/src/components/layout/AppBottomNav.vue b/frontend/src/components/layout/AppBottomNav.vue new file mode 100644 index 0000000..b5e8da4 --- /dev/null +++ b/frontend/src/components/layout/AppBottomNav.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000..5138f64 --- /dev/null +++ b/frontend/src/components/layout/AppSidebar.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/src/components/layout/AppTopbar.vue b/frontend/src/components/layout/AppTopbar.vue new file mode 100644 index 0000000..af1709f --- /dev/null +++ b/frontend/src/components/layout/AppTopbar.vue @@ -0,0 +1,526 @@ + + + + + diff --git a/frontend/src/components/ui/AchievementBadge.vue b/frontend/src/components/ui/AchievementBadge.vue new file mode 100644 index 0000000..df8a6dd --- /dev/null +++ b/frontend/src/components/ui/AchievementBadge.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/components/ui/AppIcon.vue b/frontend/src/components/ui/AppIcon.vue new file mode 100644 index 0000000..5377b70 --- /dev/null +++ b/frontend/src/components/ui/AppIcon.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/src/components/ui/CoinChip.vue b/frontend/src/components/ui/CoinChip.vue new file mode 100644 index 0000000..d9e7b4b --- /dev/null +++ b/frontend/src/components/ui/CoinChip.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/components/ui/DataTable.vue b/frontend/src/components/ui/DataTable.vue new file mode 100644 index 0000000..a241d63 --- /dev/null +++ b/frontend/src/components/ui/DataTable.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/components/ui/EmptyState.vue b/frontend/src/components/ui/EmptyState.vue new file mode 100644 index 0000000..ec57f92 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/components/ui/EnrollmentLimitModal.vue b/frontend/src/components/ui/EnrollmentLimitModal.vue new file mode 100644 index 0000000..faaf44a --- /dev/null +++ b/frontend/src/components/ui/EnrollmentLimitModal.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/ui/FilterChips.vue b/frontend/src/components/ui/FilterChips.vue new file mode 100644 index 0000000..3f06faf --- /dev/null +++ b/frontend/src/components/ui/FilterChips.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/frontend/src/components/ui/GlassCard.vue b/frontend/src/components/ui/GlassCard.vue new file mode 100644 index 0000000..dd1c3c4 --- /dev/null +++ b/frontend/src/components/ui/GlassCard.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/frontend/src/components/ui/LectureCard.vue b/frontend/src/components/ui/LectureCard.vue new file mode 100644 index 0000000..56c1947 --- /dev/null +++ b/frontend/src/components/ui/LectureCard.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/frontend/src/components/ui/LoadingSpinner.vue b/frontend/src/components/ui/LoadingSpinner.vue new file mode 100644 index 0000000..a4c1c1d --- /dev/null +++ b/frontend/src/components/ui/LoadingSpinner.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/components/ui/ModalDialog.vue b/frontend/src/components/ui/ModalDialog.vue new file mode 100644 index 0000000..83d1148 --- /dev/null +++ b/frontend/src/components/ui/ModalDialog.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/frontend/src/components/ui/ProgressBar.vue b/frontend/src/components/ui/ProgressBar.vue new file mode 100644 index 0000000..cb199e8 --- /dev/null +++ b/frontend/src/components/ui/ProgressBar.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/components/ui/SearchInput.vue b/frontend/src/components/ui/SearchInput.vue new file mode 100644 index 0000000..ee0f599 --- /dev/null +++ b/frontend/src/components/ui/SearchInput.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/frontend/src/components/ui/StatsWidget.vue b/frontend/src/components/ui/StatsWidget.vue new file mode 100644 index 0000000..7fc520c --- /dev/null +++ b/frontend/src/components/ui/StatsWidget.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/components/ui/StatusBadge.vue b/frontend/src/components/ui/StatusBadge.vue new file mode 100644 index 0000000..33cc1f6 --- /dev/null +++ b/frontend/src/components/ui/StatusBadge.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/components/ui/ToastNotification.vue b/frontend/src/components/ui/ToastNotification.vue new file mode 100644 index 0000000..da09e54 --- /dev/null +++ b/frontend/src/components/ui/ToastNotification.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/frontend/src/icons/index.ts b/frontend/src/icons/index.ts new file mode 100644 index 0000000..4056ba8 --- /dev/null +++ b/frontend/src/icons/index.ts @@ -0,0 +1,127 @@ +import alarm from '@/assets/icons/alarm.svg?raw' +import alertTriangle from '@/assets/icons/alert-triangle.svg?raw' +import bell from '@/assets/icons/bell.svg?raw' +import book2 from '@/assets/icons/book-2.svg?raw' +import books from '@/assets/icons/books.svg?raw' +import building from '@/assets/icons/building.svg?raw' +import bulb from '@/assets/icons/bulb.svg?raw' +import calendar from '@/assets/icons/calendar.svg?raw' +import calendarEvent from '@/assets/icons/calendar-event.svg?raw' +import chartBar from '@/assets/icons/chart-bar.svg?raw' +import chartLine from '@/assets/icons/chart-line.svg?raw' +import circleCheck from '@/assets/icons/circle-check.svg?raw' +import circleX from '@/assets/icons/circle-x.svg?raw' +import clipboardList from '@/assets/icons/clipboard-list.svg?raw' +import clock from '@/assets/icons/clock.svg?raw' +import coin from '@/assets/icons/coin.svg?raw' +import handStop from '@/assets/icons/hand-stop.svg?raw' +import home from '@/assets/icons/home.svg?raw' +import inbox from '@/assets/icons/inbox.svg?raw' +import infoCircle from '@/assets/icons/info-circle.svg?raw' +import lock from '@/assets/icons/lock.svg?raw' +import logout from '@/assets/icons/logout.svg?raw' +import mapPin from '@/assets/icons/map-pin.svg?raw' +import messageCircle from '@/assets/icons/message-circle.svg?raw' +import moodNeutral from '@/assets/icons/mood-neutral.svg?raw' +import robot from '@/assets/icons/robot.svg?raw' +import search from '@/assets/icons/search.svg?raw' +import shield from '@/assets/icons/shield.svg?raw' +import sparkles from '@/assets/icons/sparkles.svg?raw' +import star from '@/assets/icons/star.svg?raw' +import stopwatch from '@/assets/icons/stopwatch.svg?raw' +import thumbDown from '@/assets/icons/thumb-down.svg?raw' +import thumbUp from '@/assets/icons/thumb-up.svg?raw' +import trophy from '@/assets/icons/trophy.svg?raw' +import user from '@/assets/icons/user.svg?raw' +import users from '@/assets/icons/users.svg?raw' +import world from '@/assets/icons/world.svg?raw' + +export const iconSvgs = { + alarm, + 'alert-triangle': alertTriangle, + bell, + 'book-2': book2, + books, + building, + bulb, + calendar, + 'calendar-event': calendarEvent, + 'chart-bar': chartBar, + 'chart-line': chartLine, + 'circle-check': circleCheck, + 'circle-x': circleX, + 'clipboard-list': clipboardList, + clock, + coin, + 'hand-stop': handStop, + home, + inbox, + 'info-circle': infoCircle, + lock, + logout, + 'map-pin': mapPin, + 'message-circle': messageCircle, + 'mood-neutral': moodNeutral, + robot, + search, + shield, + sparkles, + star, + stopwatch, + 'thumb-down': thumbDown, + 'thumb-up': thumbUp, + trophy, + user, + users, + world, +} as const + +export type IconName = keyof typeof iconSvgs + +export const emojiToIcon: Record = { + '⏰': 'alarm', + '⚠️': 'alert-triangle', + '🔔': 'bell', + '📖': 'book-2', + '📚': 'books', + '🏛': 'building', + '💡': 'bulb', + '📅': 'calendar', + '🗓️': 'calendar-event', + '📊': 'chart-bar', + '📈': 'chart-line', + '✅': 'circle-check', + '❌': 'circle-x', + '📋': 'clipboard-list', + '⏱': 'stopwatch', + '⏱️': 'stopwatch', + '🕒': 'clock', + '💰': 'coin', + '👋': 'hand-stop', + '🏠': 'home', + '📭': 'inbox', + ℹ️: 'info-circle', + '🔒': 'lock', + '🚪': 'logout', + '📍': 'map-pin', + '💬': 'message-circle', + '😐': 'mood-neutral', + '🤖': 'robot', + '🔍': 'search', + '🛡️': 'shield', + '✨': 'sparkles', + '⭐': 'star', + '👍': 'thumb-up', + '👎': 'thumb-down', + '🏆': 'trophy', + '👤': 'user', + '👥': 'users', + '🌍': 'world', + '🌐': 'world', +} as const + +export function normalizeIconName(input?: string | null): IconName | undefined { + if (!input) return undefined + if (input in iconSvgs) return input as IconName + return emojiToIcon[input] +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..5dcad83 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,14 @@ +import './assets/main.css' + +import { createApp } from 'vue' +import { createPinia } from 'pinia' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..abc1996 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,135 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/login', + name: 'login', + component: () => import('@/views/auth/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/auth/callback', + name: 'auth-callback', + component: () => import('@/views/auth/AuthCallbackView.vue'), + meta: { public: true }, + }, + + // Student + { + path: '/', + name: 'dashboard', + component: () => import('@/views/student/DashboardView.vue'), + meta: { role: 'student' }, + }, + { + path: '/catalog', + name: 'catalog', + component: () => import('@/views/student/CatalogView.vue'), + meta: { role: 'student' }, + }, + { + path: '/lecture/:id', + name: 'lecture-detail', + component: () => import('@/views/student/LectureDetailView.vue'), + meta: { role: 'student' }, + }, + { + path: '/my-lectures', + name: 'my-lectures', + component: () => import('@/views/student/MyLecturesView.vue'), + meta: { role: 'student' }, + }, + { + path: '/review/:id', + name: 'review-form', + component: () => import('@/views/student/ReviewFormView.vue'), + meta: { role: 'student' }, + }, + { + path: '/profile', + name: 'profile', + component: () => import('@/views/student/ProfileView.vue'), + }, + { + path: '/notifications', + name: 'notifications', + component: () => import('@/views/student/NotificationsView.vue'), + }, + + // Teacher + { + path: '/teacher', + name: 'teacher-dashboard', + component: () => import('@/views/teacher/TeacherDashboardView.vue'), + meta: { role: 'teacher' }, + }, + { + path: '/teacher/lectures', + name: 'teacher-lectures', + component: () => import('@/views/teacher/TeacherLecturesView.vue'), + meta: { role: 'teacher' }, + }, + { + path: '/teacher/analytics', + name: 'teacher-analytics', + component: () => import('@/views/teacher/TeacherAnalyticsView.vue'), + meta: { role: 'teacher' }, + }, + + // Admin + { + path: '/admin', + name: 'admin-dashboard', + component: () => import('@/views/admin/AdminDashboardView.vue'), + meta: { role: 'admin' }, + }, + { + path: '/admin/users', + name: 'admin-users', + component: () => import('@/views/admin/AdminUsersView.vue'), + meta: { role: 'admin' }, + }, + { + path: '/admin/lectures', + name: 'admin-lectures', + component: () => import('@/views/admin/AdminLecturesView.vue'), + meta: { role: 'admin' }, + }, + { path: '/admin/llm-queue', redirect: '/admin/reviews' }, + { + path: '/admin/reviews', + name: 'admin-reviews', + component: () => import('@/views/admin/AdminReviewsView.vue'), + meta: { role: 'admin' }, + }, + + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) + +router.beforeEach(async (to) => { + const auth = useAuthStore() + const resolveDefaultRoute = () => { + if (auth.user?.activeRole === 'teacher') return '/teacher' + if (auth.user?.activeRole === 'admin') return '/admin' + return '/' + } + if (!auth.initialized && !to.meta.public) { + await auth.initialize() + } + if (!to.meta.public && !auth.isAuthenticated) { + return '/login' + } + if ( + to.meta.role && + auth.user && + !auth.user.roles.includes(to.meta.role as 'student' | 'teacher' | 'admin') + ) { + return resolveDefaultRoute() + } +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..b52c9ca --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,173 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { authApi } from '@/api' +import { mapApiUser } from '@/api/mappers' +import { buildApiUrl, setApiAccessToken } from '@/api/client' +import type { AuthResponse } from '@/api/types' +import type { User } from '@/types' + +const TOKEN_STORAGE_KEY = 'universe.accessToken' +const ACTIVE_ROLE_STORAGE_KEY = 'universe.activeRole' + +function restoreStoredActiveRole(mappedUser: User) { + const storedRole = localStorage.getItem(ACTIVE_ROLE_STORAGE_KEY) as User['activeRole'] | null + + if (!storedRole || !mappedUser.roles.includes(storedRole)) return mappedUser + + return { ...mappedUser, activeRole: storedRole } +} + +function applyAuthResponse(response: AuthResponse) { + localStorage.setItem(TOKEN_STORAGE_KEY, response.accessToken) + setApiAccessToken(response.accessToken) + return restoreStoredActiveRole(mapApiUser(response.user)) +} + +function getAuthReturnUrl() { + return import.meta.env.VITE_AUTH_RETURN_URL || '/auth/callback' +} + +function getAbsoluteAuthReturnUrl() { + return new URL(getAuthReturnUrl(), window.location.origin).toString() +} + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const loading = ref(false) + const initialized = ref(false) + const error = ref(null) + const accessToken = ref(localStorage.getItem(TOKEN_STORAGE_KEY)) + + if (accessToken.value) setApiAccessToken(accessToken.value) + + const isAuthenticated = computed(() => Boolean(user.value && accessToken.value)) + + async function hydrateFromResponse(response: AuthResponse) { + accessToken.value = response.accessToken + user.value = applyAuthResponse(response) + error.value = null + } + + async function initialize() { + if (initialized.value) return isAuthenticated.value + loading.value = true + error.value = null + + try { + const refreshed = await authApi.refresh() + await hydrateFromResponse(refreshed) + const me = await authApi.me() + user.value = restoreStoredActiveRole(mapApiUser(me)) + return true + } catch (refreshError) { + if (accessToken.value) { + try { + const me = await authApi.me() + user.value = restoreStoredActiveRole(mapApiUser(me)) + return true + } catch { + // Fall through to local cleanup below. + } + } + clearSession() + error.value = refreshError instanceof Error ? refreshError.message : null + return false + } finally { + initialized.value = true + loading.value = false + } + } + + function startMicrosoftLogin() { + window.location.assign(buildApiUrl('/auth/login/microsoft', { returnUrl: getAuthReturnUrl() })) + return true + } + + async function completeMicrosoftLogin(code: string, _state: string | null) { + loading.value = true + error.value = null + try { + const redirectUri = getAbsoluteAuthReturnUrl() + const response = await authApi.loginMicrosoft(code, redirectUri) + await hydrateFromResponse(response) + initialized.value = true + return true + } catch (err) { + clearSession() + error.value = err instanceof Error ? err.message : 'Ошибка авторизации через Microsoft.' + throw err + } finally { + loading.value = false + } + } + + async function completeTokenLogin(token: string) { + loading.value = true + error.value = null + try { + accessToken.value = token + localStorage.setItem(TOKEN_STORAGE_KEY, token) + setApiAccessToken(token) + const me = await authApi.me() + user.value = restoreStoredActiveRole(mapApiUser(me)) + initialized.value = true + return true + } catch (err) { + clearSession() + error.value = + err instanceof Error ? err.message : 'Не удалось получить пользователя после входа.' + throw err + } finally { + loading.value = false + } + } + + async function logout() { + loading.value = true + try { + await authApi.logout() + } catch { + // Local cleanup is still correct if the server session is already gone. + } finally { + clearSession() + initialized.value = true + loading.value = false + } + } + + function clearSession() { + user.value = null + accessToken.value = null + localStorage.removeItem(TOKEN_STORAGE_KEY) + localStorage.removeItem(ACTIVE_ROLE_STORAGE_KEY) + setApiAccessToken(null) + } + + function setUser(nextUser: User) { + user.value = nextUser + } + + function setActiveRole(role: User['activeRole']) { + if (!user.value || !user.value.roles.includes(role)) return false + localStorage.setItem(ACTIVE_ROLE_STORAGE_KEY, role) + user.value = { ...user.value, activeRole: role } + return true + } + + return { + user, + accessToken, + isAuthenticated, + loading, + initialized, + error, + initialize, + startMicrosoftLogin, + completeMicrosoftLogin, + completeTokenLogin, + logout, + clearSession, + setUser, + setActiveRole, + } +}) diff --git a/frontend/src/stores/counter.ts b/frontend/src/stores/counter.ts new file mode 100644 index 0000000..b6757ba --- /dev/null +++ b/frontend/src/stores/counter.ts @@ -0,0 +1,12 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + const doubleCount = computed(() => count.value * 2) + function increment() { + count.value++ + } + + return { count, doubleCount, increment } +}) diff --git a/frontend/src/stores/lectures.ts b/frontend/src/stores/lectures.ts new file mode 100644 index 0000000..5481ac5 --- /dev/null +++ b/frontend/src/stores/lectures.ts @@ -0,0 +1,128 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { lecturesApi, usersApi } from '@/api' +import { mapApiLecture } from '@/api/mappers' +import type { Lecture } from '@/types' +import type { LectureQuery } from '@/api/types' +import { useUserStore } from './user' + +export const useLecturesStore = defineStore('lectures', () => { + const lectures = ref([]) + const registered = ref([]) + const loading = ref(false) + const error = ref(null) + + const all = computed(() => lectures.value) + const registeredIds = computed(() => registered.value) + const registeredLectures = computed(() => + lectures.value.filter((l) => registered.value.includes(l.id) || l.registered), + ) + + async function fetchLectures(query: LectureQuery = {}) { + loading.value = true + error.value = null + try { + const payload = await lecturesApi.list({ PageSize: 100, ...query }) + lectures.value = payload.map(mapApiLecture) + registered.value = lectures.value.filter((l) => l.registered).map((l) => l.id) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекции.' + } finally { + loading.value = false + } + } + + async function fetchLecture(id: string) { + error.value = null + try { + const lecture = mapApiLecture(await lecturesApi.get(id)) + const index = lectures.value.findIndex((item) => item.id === lecture.id) + if (index >= 0) lectures.value[index] = lecture + else lectures.value.push(lecture) + if (lecture.registered && !registered.value.includes(lecture.id)) + registered.value.push(lecture.id) + return lecture + } catch (err) { + error.value = err instanceof Error ? err.message : 'Не удалось загрузить лекцию.' + return lectures.value.find((item) => item.id === id) + } + } + + async function fetchRegisteredForCurrentUser() { + try { + const enrollments = await usersApi.myEnrollments() + const mapped = enrollments.map(mapApiLecture) + registered.value = mapped.map((lecture) => lecture.id) + if (mapped.length) { + mapped.forEach((lecture) => { + const index = lectures.value.findIndex((item) => item.id === lecture.id) + if (index >= 0) + lectures.value[index] = { ...lectures.value[index], ...lecture, registered: true } + else lectures.value.push({ ...lecture, registered: true }) + }) + } + } catch { + // Some backend builds return an empty 200 for this endpoint; catalog detail still carries isEnrolled. + } + } + + async function register(lectureId: string) { + const lecture = lectures.value.find((item) => item.id === lectureId) + if ( + !lecture || + lecture.freeSeats === 0 || + lecture.registrationClosed || + registered.value.includes(lectureId) + ) + return + const userStore = useUserStore() + if (!userStore.hasEnrollmentSlotAvailable) { + throw new Error('Лимит записей достигнут. Отмените одну из записей или повысьте уровень.') + } + + await lecturesApi.enroll(lectureId) + registered.value.push(lectureId) + lecture.freeSeats = Math.max(lecture.freeSeats - 1, 0) + lecture.enrolledSeats += 1 + lecture.registered = true + userStore.adjustActiveEnrollments(1) + await userStore.fetchStats().catch(() => undefined) + } + + async function unregister(lectureId: string) { + await lecturesApi.unenroll(lectureId) + const userStore = useUserStore() + registered.value = registered.value.filter((id) => id !== lectureId) + const lecture = lectures.value.find((item) => item.id === lectureId) + if (lecture) { + lecture.freeSeats = Math.min(lecture.freeSeats + 1, lecture.totalSeats) + lecture.enrolledSeats = Math.max(lecture.enrolledSeats - 1, 0) + lecture.registered = false + } + userStore.adjustActiveEnrollments(-1) + await userStore.fetchStats().catch(() => undefined) + } + + function isRegistered(lectureId: string) { + return ( + registered.value.includes(lectureId) || + Boolean(lectures.value.find((item) => item.id === lectureId)?.registered) + ) + } + + return { + lectures, + registered, + loading, + error, + all, + registeredIds, + registeredLectures, + fetchLectures, + fetchLecture, + fetchRegisteredForCurrentUser, + register, + unregister, + isRegistered, + } +}) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts new file mode 100644 index 0000000..ea13800 --- /dev/null +++ b/frontend/src/stores/user.ts @@ -0,0 +1,138 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { achievementsApi, notificationsApi, usersApi } from '@/api' +import { mapApiAchievement, mapApiCoinTransaction, mapApiNotification } from '@/api/mappers' +import type { UserStatsDto } from '@/api/types' +import type { Achievement, CoinTransaction, Notification } from '@/types' +import { useAuthStore } from './auth' + +export const useUserStore = defineStore('user', () => { + const achievements = ref([]) + const notifications = ref([]) + const coinHistory = ref([]) + const loading = ref(false) + const error = ref(null) + const activeEnrollments = computed(() => useAuthStore().user?.activeEnrollments ?? 0) + const enrollmentSlotLimit = computed(() => useAuthStore().user?.enrollmentSlotLimit ?? 0) + const hasEnrollmentSlotAvailable = computed( + () => enrollmentSlotLimit.value === 0 || activeEnrollments.value < enrollmentSlotLimit.value, + ) + + function applyStats(stats: UserStatsDto) { + const auth = useAuthStore() + if (!auth.user) return + + auth.setUser({ + ...auth.user, + coins: stats.coins, + level: stats.level, + xp: stats.xp, + currentLevelXp: stats.currentLevelXp, + nextLevelXp: stats.nextLevelXp, + lecturesAttended: stats.attendedLectures, + hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10, + achievements: Array.from({ length: stats.achievementsCount }, (_, index) => + String(index + 1), + ), + activeEnrollments: stats.activeEnrollments, + enrollmentSlotLimit: stats.enrollmentSlotLimit, + enrollmentSlotRules: stats.enrollmentSlotRules, + }) + } + + async function fetchStats() { + error.value = null + try { + const stats = await usersApi.myStats() + applyStats(stats) + return stats + } catch (err) { + error.value = err instanceof Error ? err.message : 'Не удалось загрузить статистику профиля.' + throw err + } + } + + function adjustActiveEnrollments(delta: number) { + const auth = useAuthStore() + if (!auth.user || typeof auth.user.activeEnrollments !== 'number') return + + auth.setUser({ + ...auth.user, + activeEnrollments: Math.max(auth.user.activeEnrollments + delta, 0), + }) + } + + async function fetchStudentData() { + const auth = useAuthStore() + if (!auth.user) return + + loading.value = true + error.value = null + try { + const [stats, achievementPayload, transactions] = await Promise.all([ + usersApi.myStats(), + usersApi.myAchievements(), + usersApi.myTransactions(), + ]) + const [achievementCatalog, notificationPayload] = await Promise.all([ + achievementsApi.list(), + notificationsApi.list(), + ]) + + applyStats(stats) + const unlocked = new Map( + achievementPayload.map((item) => { + const achievement = mapApiAchievement(item) + return [achievement.id, achievement] + }), + ) + const catalogIds = new Set(achievementCatalog.map((item) => String(item.id))) + const lockedAndUnlocked = achievementCatalog.map((item) => { + const achievement = mapApiAchievement(item) + return unlocked.get(achievement.id) ?? achievement + }) + const unlockedOutsideCatalog = achievementPayload + .map(mapApiAchievement) + .filter((item) => !catalogIds.has(item.id)) + + achievements.value = [...lockedAndUnlocked, ...unlockedOutsideCatalog].sort( + (a, b) => Number(a.id) - Number(b.id), + ) + coinHistory.value = transactions.map(mapApiCoinTransaction) + notifications.value = notificationPayload.map(mapApiNotification) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Не удалось загрузить данные профиля.' + } finally { + loading.value = false + } + } + + async function fetchNotifications() { + const payload = await notificationsApi.list() + notifications.value = payload.map(mapApiNotification) + } + + async function markAllRead() { + await notificationsApi.markAllRead() + notifications.value.forEach((n) => (n.read = true)) + } + + const unreadCount = () => notifications.value.filter((n) => !n.read).length + + return { + achievements, + notifications, + coinHistory, + loading, + error, + activeEnrollments, + enrollmentSlotLimit, + hasEnrollmentSlotAvailable, + fetchStats, + adjustActiveEnrollments, + fetchStudentData, + fetchNotifications, + markAllRead, + unreadCount, + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..d69583e --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,96 @@ +export type UserRole = 'student' | 'teacher' | 'admin' + +export interface User { + id: number + name: string + email: string + roles: UserRole[] + activeRole: UserRole + avatar?: string + institute?: string + department?: string + year?: number + direction?: string + coins: number + level: number + xp?: number + currentLevelXp?: number + nextLevelXp?: number | null + lecturesAttended?: number + hoursLearned?: number + achievements?: string[] + activeEnrollments?: number + enrollmentSlotLimit?: number + enrollmentSlotRules?: EnrollmentSlotRule[] +} + +export interface EnrollmentSlotRule { + level: number + slots: number +} + +export interface Lecture { + id: string + teacherId?: number | null + title: string + description: string + teacher: string + teacherTitle?: string + department?: string + institute: string + date: string + time: string + duration: number + building: string + room?: string + format: 'online' | 'offline' + totalSeats: number + enrolledSeats: number + freeSeats: number + registrationClosed?: boolean + tags: string[] + rating: number + reviewCount: number + status?: 'upcoming' | 'ongoing' | 'completed' + registered?: boolean +} + +export interface Review { + id: string + lectureId: string + userId: string + userName: string + text: string + sentiment: 'positive' | 'neutral' | 'negative' + coins?: number + createdAt: string + status: 'pending' | 'analyzing' | 'done' | 'rejected' + quality?: number +} + +export interface Achievement { + id: string + title: string + description: string + icon: string + unlocked: boolean + unlockedAt?: string + coins?: number +} + +export interface Notification { + id: string + type: 'reminder' | 'schedule-change' | 'achievement' | 'coins' | 'recommendation' + title: string + body: string + read: boolean + createdAt: string +} + +export interface CoinTransaction { + id: string + date: string + description: string + amount: number + type: 'earned' | 'spent' +} diff --git a/frontend/src/utils/formatUserName.ts b/frontend/src/utils/formatUserName.ts new file mode 100644 index 0000000..5a23636 --- /dev/null +++ b/frontend/src/utils/formatUserName.ts @@ -0,0 +1,12 @@ +export function formatUserName(name?: string | null) { + if (!name) return '' + + const parts = name.trim().split(' ').filter(Boolean) + + // Если имя состоит из одного слова, оставляем его как есть. + if (parts.length === 1) return parts[0] + + // Бэкенд хранит displayName одной строкой, а в интерфейсе нам нужен формат + // "имя + фамилия" для верхней панели и приветствия. + return `${parts[1]} ${parts[0]}` +} diff --git a/frontend/src/views/AboutView.vue b/frontend/src/views/AboutView.vue new file mode 100644 index 0000000..79c9b0f --- /dev/null +++ b/frontend/src/views/AboutView.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..d5c0217 --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,9 @@ + + + diff --git a/frontend/src/views/admin/AdminDashboardView.vue b/frontend/src/views/admin/AdminDashboardView.vue new file mode 100644 index 0000000..9559ee9 --- /dev/null +++ b/frontend/src/views/admin/AdminDashboardView.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue new file mode 100644 index 0000000..17f4616 --- /dev/null +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -0,0 +1,827 @@ + + + + + diff --git a/frontend/src/views/admin/AdminUsersView.vue b/frontend/src/views/admin/AdminUsersView.vue new file mode 100644 index 0000000..e0580fd --- /dev/null +++ b/frontend/src/views/admin/AdminUsersView.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/src/views/auth/AuthCallbackView.vue b/frontend/src/views/auth/AuthCallbackView.vue new file mode 100644 index 0000000..7d81d35 --- /dev/null +++ b/frontend/src/views/auth/AuthCallbackView.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue new file mode 100644 index 0000000..b7a06ab --- /dev/null +++ b/frontend/src/views/auth/LoginView.vue @@ -0,0 +1,549 @@ + + + + + diff --git a/frontend/src/views/student/CatalogView.vue b/frontend/src/views/student/CatalogView.vue new file mode 100644 index 0000000..e309f17 --- /dev/null +++ b/frontend/src/views/student/CatalogView.vue @@ -0,0 +1,511 @@ + + + + + diff --git a/frontend/src/views/student/DashboardView.vue b/frontend/src/views/student/DashboardView.vue new file mode 100644 index 0000000..cc6225c --- /dev/null +++ b/frontend/src/views/student/DashboardView.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/frontend/src/views/student/LectureDetailView.vue b/frontend/src/views/student/LectureDetailView.vue new file mode 100644 index 0000000..807d871 --- /dev/null +++ b/frontend/src/views/student/LectureDetailView.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/views/student/MyLecturesView.vue b/frontend/src/views/student/MyLecturesView.vue new file mode 100644 index 0000000..e0c8563 --- /dev/null +++ b/frontend/src/views/student/MyLecturesView.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/views/student/NotificationsView.vue b/frontend/src/views/student/NotificationsView.vue new file mode 100644 index 0000000..61c6742 --- /dev/null +++ b/frontend/src/views/student/NotificationsView.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/views/student/ProfileView.vue b/frontend/src/views/student/ProfileView.vue new file mode 100644 index 0000000..510db57 --- /dev/null +++ b/frontend/src/views/student/ProfileView.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/frontend/src/views/student/ReviewFormView.vue b/frontend/src/views/student/ReviewFormView.vue new file mode 100644 index 0000000..9f28a4b --- /dev/null +++ b/frontend/src/views/student/ReviewFormView.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/frontend/src/views/teacher/TeacherAnalyticsView.vue b/frontend/src/views/teacher/TeacherAnalyticsView.vue new file mode 100644 index 0000000..bbf4798 --- /dev/null +++ b/frontend/src/views/teacher/TeacherAnalyticsView.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/frontend/src/views/teacher/TeacherDashboardView.vue b/frontend/src/views/teacher/TeacherDashboardView.vue new file mode 100644 index 0000000..778eec1 --- /dev/null +++ b/frontend/src/views/teacher/TeacherDashboardView.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/views/teacher/TeacherLecturesView.vue b/frontend/src/views/teacher/TeacherLecturesView.vue new file mode 100644 index 0000000..15e3bb0 --- /dev/null +++ b/frontend/src/views/teacher/TeacherLecturesView.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..c0f2d86 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,18 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + // Extra safety for array and object lookups, but may have false positives. + "noUncheckedIndexedAccess": true, + + // Path mapping for cleaner imports. + "paths": { + "@/*": ["./src/*"] + }, + + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. + // Specified here to keep it out of the root directory. + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..66b5e57 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..c9b2bad --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,27 @@ +// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping. +{ + "extends": "@tsconfig/node24/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + // Most tools use transpilation instead of Node.js's native type-stripping. + // Bundler mode provides a smoother developer experience. + "module": "preserve", + "moduleResolution": "bundler", + + // Include Node.js types and avoid accidentally including other `@types/*` packages. + "types": ["node"], + + // Disable emitting output during `vue-tsc --build`, which is used for type-checking only. + "noEmit": true, + + // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. + // Specified here to keep it out of the root directory. + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..7e794b2 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,33 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + // loadEnv(..., '') — чтобы получить и НЕ-VITE переменные при необходимости, + // но мы используем именно VITE_* для простоты прокидывания из AppHost. + const env = loadEnv(mode, process.cwd(), '') + const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:5019' + + return { + plugins: [vue(), vueDevTools()], + server: { + host: true, + port: 5173, + proxy: { + '/api': { + target: apiProxyTarget, + changeOrigin: true, + secure: false, + }, + }, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + } +})