From ee627e4878135c6b9ed7f1d45525f80d443561dc Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Tue, 28 Apr 2026 15:53:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20AP?= =?UTF-8?q?I=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LlmProcessingBackgroundService.cs | 36 ++++ .../Controllers/AchievementsController.cs | 35 ++++ .../Controllers/AuthController.cs | 71 ++++++++ .../Controllers/CoursesController.cs | 46 +++++ .../Controllers/LecturesController.cs | 64 +++++++ .../Controllers/LocationsController.cs | 35 ++++ .../Controllers/ReviewsController.cs | 46 +++++ .../Controllers/SyncController.cs | 31 ++++ .../Controllers/TagsController.cs | 40 +++++ .../Controllers/UsersController.cs | 81 +++++++++ backend/UniVerse.Api/Dockerfile | 23 +++ .../Middleware/ExceptionHandlingMiddleware.cs | 54 ++++++ .../Middleware/RequestLoggingMiddleware.cs | 24 +++ backend/UniVerse.Api/Program.cs | 159 ++++++++++++++++++ .../Properties/launchSettings.json | 14 ++ backend/UniVerse.Api/UniVerse.Api.csproj | 36 ++++ .../UniVerse.Api/appsettings.Development.json | 8 + backend/UniVerse.Api/appsettings.json | 47 ++++++ backend/UniVerse.sln | 22 ++- backend/nuget.config | 8 + 20 files changed, 878 insertions(+), 2 deletions(-) create mode 100644 backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs create mode 100644 backend/UniVerse.Api/Controllers/AchievementsController.cs create mode 100644 backend/UniVerse.Api/Controllers/AuthController.cs create mode 100644 backend/UniVerse.Api/Controllers/CoursesController.cs create mode 100644 backend/UniVerse.Api/Controllers/LecturesController.cs create mode 100644 backend/UniVerse.Api/Controllers/LocationsController.cs create mode 100644 backend/UniVerse.Api/Controllers/ReviewsController.cs create mode 100644 backend/UniVerse.Api/Controllers/SyncController.cs create mode 100644 backend/UniVerse.Api/Controllers/TagsController.cs create mode 100644 backend/UniVerse.Api/Controllers/UsersController.cs create mode 100644 backend/UniVerse.Api/Dockerfile create mode 100644 backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 backend/UniVerse.Api/Middleware/RequestLoggingMiddleware.cs create mode 100644 backend/UniVerse.Api/Program.cs create mode 100644 backend/UniVerse.Api/Properties/launchSettings.json create mode 100644 backend/UniVerse.Api/UniVerse.Api.csproj create mode 100644 backend/UniVerse.Api/appsettings.Development.json create mode 100644 backend/UniVerse.Api/appsettings.json create mode 100644 backend/nuget.config diff --git a/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs b/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs new file mode 100644 index 0000000..ef5b733 --- /dev/null +++ b/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs @@ -0,0 +1,36 @@ +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/Controllers/AchievementsController.cs b/backend/UniVerse.Api/Controllers/AchievementsController.cs new file mode 100644 index 0000000..62cda9c --- /dev/null +++ b/backend/UniVerse.Api/Controllers/AchievementsController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Achievements; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/achievements")] +[Authorize] +public class AchievementsController : ControllerBase +{ + private readonly IAchievementService _achievements; + public AchievementsController(IAchievementService achievements) => _achievements = achievements; + + [HttpGet] + public async Task GetAll() => Ok(await _achievements.GetAllAsync()); + + [HttpGet("{id:int}")] + public async Task> Get(int id) => Ok(await _achievements.GetByIdAsync(id)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task> Create([FromBody] CreateAchievementRequest req) => + CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] UpdateAchievementRequest req) => + Ok(await _achievements.UpdateAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}")] + 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 new file mode 100644 index 0000000..369f800 --- /dev/null +++ b/backend/UniVerse.Api/Controllers/AuthController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Auth; +using UniVerse.Application.Interfaces; +using System.Security.Claims; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/auth")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _auth; + public AuthController(IAuthService auth) => _auth = auth; + + [HttpPost("login/microsoft")] + public async Task> LoginMicrosoft([FromBody] LoginMicrosoftRequest request) + { + var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode); + return Ok(result); + } + + [HttpPost("login/dev")] + 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); + SetRefreshTokenCookie(result.AccessToken); // simplified: set cookie logic + return Ok(result); + } + + [HttpPost("refresh")] + public async Task> Refresh() + { + var refreshToken = Request.Cookies["refreshToken"]; + if (string.IsNullOrEmpty(refreshToken)) return Unauthorized(); + var result = await _auth.RefreshTokenAsync(refreshToken); + return Ok(result); + } + + [Authorize] + [HttpPost("logout")] + public async Task Logout() + { + var refreshToken = Request.Cookies["refreshToken"]; + if (!string.IsNullOrEmpty(refreshToken)) + await _auth.RevokeRefreshTokenAsync(refreshToken); + Response.Cookies.Delete("refreshToken"); + return NoContent(); + } + + [Authorize] + [HttpGet("me")] + public async Task Me() + { + var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") ?? "0"); + var user = await _auth.GetCurrentUserAsync(userId); + return Ok(user); + } + + private void SetRefreshTokenCookie(string token) + { + Response.Cookies.Append("refreshToken", token, new CookieOptions + { + HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, + Expires = DateTime.UtcNow.AddDays(30) + }); + } +} diff --git a/backend/UniVerse.Api/Controllers/CoursesController.cs b/backend/UniVerse.Api/Controllers/CoursesController.cs new file mode 100644 index 0000000..e70369d --- /dev/null +++ b/backend/UniVerse.Api/Controllers/CoursesController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Courses; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/courses")] +[Authorize] +public class CoursesController : ControllerBase +{ + private readonly ICourseService _courses; + public CoursesController(ICourseService courses) => _courses = courses; + + [HttpGet] + public async Task GetAll([FromQuery] CourseFilterRequest filter) => + Ok(await _courses.GetAllAsync(filter)); + + [HttpGet("{id:int}")] + public async Task> Get(int id) => Ok(await _courses.GetByIdAsync(id)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task> Create([FromBody] CreateCourseRequest req) => + CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] UpdateCourseRequest req) => + Ok(await _courses.UpdateAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}")] + public async Task Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); } + + [Authorize(Roles = "Admin")] + [HttpPost("{id:int}/tags")] + public async Task AddTag(int id, [FromBody] int tagId) + { await _courses.AddTagAsync(id, tagId); return NoContent(); } + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}/tags/{tagId:int}")] + public async Task RemoveTag(int id, int tagId) + { await _courses.RemoveTagAsync(id, tagId); return NoContent(); } +} diff --git a/backend/UniVerse.Api/Controllers/LecturesController.cs b/backend/UniVerse.Api/Controllers/LecturesController.cs new file mode 100644 index 0000000..f1dc2a1 --- /dev/null +++ b/backend/UniVerse.Api/Controllers/LecturesController.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Lectures; +using UniVerse.Application.Interfaces; +using System.Security.Claims; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/lectures")] +[Authorize] +public class LecturesController : ControllerBase +{ + private readonly ILectureService _lectures; + private readonly IReviewService _reviews; + public LecturesController(ILectureService lectures, IReviewService reviews) + { _lectures = lectures; _reviews = reviews; } + private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); + + [HttpGet] + public async Task GetAll([FromQuery] LectureFilterRequest filter) => + Ok(await _lectures.GetAllAsync(filter)); + + [HttpGet("{id:int}")] + public async Task Get(int id) => + Ok(await _lectures.GetByIdAsync(id, CurrentUserId)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task> Create([FromBody] CreateLectureRequest req) => + CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req)); + + [Authorize(Roles = "Admin,Teacher")] + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] UpdateLectureRequest req) => + Ok(await _lectures.UpdateAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}")] + public async Task Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); } + + [Authorize(Roles = "Student")] + [HttpPost("{id:int}/enroll")] + public async Task Enroll(int id) { await _lectures.EnrollAsync(id, CurrentUserId); return NoContent(); } + + [Authorize(Roles = "Student")] + [HttpDelete("{id:int}/enroll")] + public async Task Unenroll(int id) { await _lectures.UnenrollAsync(id, CurrentUserId); return NoContent(); } + + [Authorize(Roles = "Admin,Teacher")] + [HttpPatch("{id:int}/attendance/{userId:int}")] + public async Task Attendance(int id, int userId, [FromBody] bool attended) + { await _lectures.MarkAttendanceAsync(id, userId, attended); return NoContent(); } + + [Authorize(Roles = "Admin,Teacher")] + [HttpGet("{id:int}/enrollments")] + public async Task Enrollments(int id, [FromQuery] PaginationRequest pagination) => + Ok(await _lectures.GetEnrollmentsAsync(id, pagination)); + + [HttpGet("{id:int}/reviews")] + public async Task Reviews(int id, [FromQuery] PaginationRequest pagination) => + Ok(await _reviews.GetByLectureAsync(id, pagination)); +} diff --git a/backend/UniVerse.Api/Controllers/LocationsController.cs b/backend/UniVerse.Api/Controllers/LocationsController.cs new file mode 100644 index 0000000..7b1e616 --- /dev/null +++ b/backend/UniVerse.Api/Controllers/LocationsController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Locations; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/locations")] +[Authorize] +public class LocationsController : ControllerBase +{ + private readonly ILocationService _locations; + public LocationsController(ILocationService locations) => _locations = locations; + + [HttpGet] + public async Task GetAll() => Ok(await _locations.GetAllAsync()); + + [HttpGet("{id:int}")] + public async Task> Get(int id) => Ok(await _locations.GetByIdAsync(id)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task> Create([FromBody] CreateLocationRequest req) => + CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] UpdateLocationRequest req) => + Ok(await _locations.UpdateAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}")] + public async Task Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); } +} diff --git a/backend/UniVerse.Api/Controllers/ReviewsController.cs b/backend/UniVerse.Api/Controllers/ReviewsController.cs new file mode 100644 index 0000000..1f59f1c --- /dev/null +++ b/backend/UniVerse.Api/Controllers/ReviewsController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Reviews; +using UniVerse.Application.Interfaces; +using System.Security.Claims; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/reviews")] +[Authorize] +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"); + + [Authorize(Roles = "Student")] + [HttpPost] + public async Task> Create([FromBody] CreateReviewRequest req) => + CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req)); + + [HttpGet("{id:int}")] + public async Task> Get(int id) => Ok(await _reviews.GetByIdAsync(id)); + + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] UpdateReviewRequest req) => + Ok(await _reviews.UpdateAsync(id, CurrentUserId, req)); + + [HttpDelete("{id:int}")] + 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)); + + [Authorize(Roles = "Admin")] + [HttpPost("{id:int}/reanalyze")] + 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 new file mode 100644 index 0000000..67bbaac --- /dev/null +++ b/backend/UniVerse.Api/Controllers/SyncController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Sync; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/sync")] +[Authorize(Roles = "Admin")] +public class SyncController : ControllerBase +{ + private readonly IScheduleSyncService _sync; + public SyncController(IScheduleSyncService sync) => _sync = sync; + + [HttpPost("schedule")] + public async Task> SyncSchedule([FromBody] SyncScheduleRequest req) => + Ok(await _sync.SyncScheduleAsync(req)); + + [HttpGet("status")] + public async Task> Status() => + Ok(await _sync.GetLastSyncStatusAsync()); + + [HttpPost("rooms")] + public async Task> SyncRooms() => + Ok(await _sync.SyncRoomsAsync()); + + [HttpPost("employees")] + 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 new file mode 100644 index 0000000..27a6bd7 --- /dev/null +++ b/backend/UniVerse.Api/Controllers/TagsController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Tags; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Enums; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/tags")] +[Authorize] +public class TagsController : ControllerBase +{ + private readonly ITagService _tags; + public TagsController(ITagService tags) => _tags = tags; + + [HttpGet] + public async Task GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) => + Ok(await _tags.GetAllAsync(type, parentId)); + + [HttpGet("{id:int}")] + public async Task> Get(int id) => Ok(await _tags.GetByIdAsync(id)); + + [HttpGet("tree")] + public async Task GetTree() => Ok(await _tags.GetTreeAsync()); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task> Create([FromBody] CreateTagRequest req) => + CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:int}")] + public async Task> Update(int id, [FromBody] UpdateTagRequest req) => + Ok(await _tags.UpdateAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:int}")] + 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 new file mode 100644 index 0000000..703ce99 --- /dev/null +++ b/backend/UniVerse.Api/Controllers/UsersController.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UniVerse.Application.DTOs.Common; +using UniVerse.Application.DTOs.Users; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Enums; +using System.Security.Claims; + +namespace UniVerse.Api.Controllers; + +[ApiController] +[Route("api/v1/users")] +[Authorize] +public class UsersController : ControllerBase +{ + private readonly IUserService _users; + private readonly IReviewService _reviews; + private readonly IGamificationService _gamification; + public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification) + { + _users = users; _reviews = reviews; _gamification = gamification; + } + private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); + + [HttpGet("{id:int}")] + public async Task> Get(int id) => Ok(await _users.GetByIdAsync(id)); + + [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)); + } + + [HttpGet("{id:int}/stats")] + public async Task> Stats(int id) => Ok(await _users.GetStatsAsync(id)); + + [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(); + } + + [HttpGet("{id:int}/reviews")] + public async Task Reviews(int id, [FromQuery] PaginationRequest pagination) => + Ok(await _reviews.GetByUserAsync(id, pagination)); + + [HttpGet("{id:int}/achievements")] + public async Task Achievements(int id) => + Ok(await _gamification.GetUserAchievementsAsync(id)); + + [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)); + } + + [Authorize(Roles = "Admin")] + [HttpGet] + public async Task GetAll([FromQuery] UserFilterRequest filter) => + Ok(await _users.GetAllAsync(filter)); + + [Authorize(Roles = "Admin")] + [HttpPatch("{id:int}/role")] + public async Task SetRole(int id, [FromBody] UserRole role) + { + await _users.SetRoleAsync(id, role); + return NoContent(); + } + + [Authorize(Roles = "Admin")] + [HttpPatch("{id:int}/active")] + public async Task SetActive(int id, [FromBody] bool isActive) + { + await _users.SetActiveAsync(id, isActive); + return NoContent(); + } +} diff --git a/backend/UniVerse.Api/Dockerfile b/backend/UniVerse.Api/Dockerfile new file mode 100644 index 0000000..2d0888c --- /dev/null +++ b/backend/UniVerse.Api/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["UniVerse/UniVerse.csproj", "UniVerse/"] +RUN dotnet restore "UniVerse/UniVerse.csproj" +COPY . . +WORKDIR "/src/UniVerse" +RUN dotnet build "./UniVerse.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./UniVerse.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "UniVerse.dll"] diff --git a/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs b/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..bd13b7e --- /dev/null +++ b/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,54 @@ +using System.Net; +using System.Text.Json; +using UniVerse.Domain.Exceptions; + +namespace UniVerse.Api.Middleware; + +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try { await _next(context); } + catch (Exception ex) { await HandleExceptionAsync(context, ex); } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var (statusCode, title) = exception switch + { + NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"), + ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"), + ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"), + UnauthorizedAccessException => ((int)HttpStatusCode.Unauthorized, "Unauthorized"), + _ => ((int)HttpStatusCode.InternalServerError, "Internal Server Error") + }; + + if (statusCode == 500) + _logger.LogError(exception, "Unhandled exception"); + else + _logger.LogWarning("Handled exception: {Message}", exception.Message); + + context.Response.ContentType = "application/problem+json"; + context.Response.StatusCode = statusCode; + + var problem = new + { + type = $"https://httpstatuses.com/{statusCode}", + title, + status = statusCode, + detail = exception.Message, + traceId = context.TraceIdentifier + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(problem, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + } +} diff --git a/backend/UniVerse.Api/Middleware/RequestLoggingMiddleware.cs b/backend/UniVerse.Api/Middleware/RequestLoggingMiddleware.cs new file mode 100644 index 0000000..6a2d025 --- /dev/null +++ b/backend/UniVerse.Api/Middleware/RequestLoggingMiddleware.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; + +namespace UniVerse.Api.Middleware; + +public class RequestLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public RequestLoggingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var sw = Stopwatch.StartNew(); + await _next(context); + sw.Stop(); + _logger.LogInformation("{Method} {Path} → {StatusCode} ({Elapsed}ms)", + context.Request.Method, context.Request.Path, + context.Response.StatusCode, sw.ElapsedMilliseconds); + } +} diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs new file mode 100644 index 0000000..2658da9 --- /dev/null +++ b/backend/UniVerse.Api/Program.cs @@ -0,0 +1,159 @@ +using System.Text; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Serilog; +using UniVerse.Api.BackgroundServices; +using UniVerse.Api.Middleware; +using UniVerse.Application.Interfaces; +using UniVerse.Infrastructure.Services; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.ExternalServices; + +var builder = WebApplication.CreateBuilder(args); + +// --- Serilog --- +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); +builder.Host.UseSerilog(); + +// --- DbContext --- +builder.Services.AddDbContext(options => +{ + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"), + npgsql => + { + npgsql.EnableRetryOnFailure(3); + npgsql.MigrationsAssembly("UniVerse.Infrastructure"); + }); +}); + +// --- Authentication --- +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + 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!!")) + }; +}); +builder.Services.AddAuthorization(); + +// --- CORS --- +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins( + builder.Configuration.GetSection("Cors:Origins").Get() + ?? ["http://localhost:5173", "http://localhost:3000"]) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); + +// --- Services DI --- +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(); + +// --- HTTP Clients --- +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["Llm:BaseUrl"] ?? "https://api.openai.com/v1/"); + client.Timeout = TimeSpan.FromSeconds(60); +}); + +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// --- Background Services --- +builder.Services.AddHostedService(); + +// --- Controllers --- +builder.Services.AddControllers() + .AddJsonOptions(o => + { + o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + +// --- Swagger --- +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "UniVerse API", + Version = "v1", + Description = "University schedule, reviews, and gamification platform" + }); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Enter your JWT token" + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } + }, + Array.Empty() + } + }); +}); + +var app = builder.Build(); + +// --- 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.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/backend/UniVerse.Api/Properties/launchSettings.json b/backend/UniVerse.Api/Properties/launchSettings.json new file mode 100644 index 0000000..385142c --- /dev/null +++ b/backend/UniVerse.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5019", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj new file mode 100644 index 0000000..0a73ba0 --- /dev/null +++ b/backend/UniVerse.Api/UniVerse.Api.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + UniVerse.Api + Linux + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + .dockerignore + + + + diff --git a/backend/UniVerse.Api/appsettings.Development.json b/backend/UniVerse.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/backend/UniVerse.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json new file mode 100644 index 0000000..461626d --- /dev/null +++ b/backend/UniVerse.Api/appsettings.json @@ -0,0 +1,47 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=universe;Username=postgres;Password=postgres" + }, + "Jwt": { + "Secret": "default-dev-secret-key-change-in-production-32chars!!", + "Issuer": "UniVerse", + "Audience": "UniVerse", + "AccessTokenExpirationMinutes": "30", + "RefreshTokenExpirationDays": "30" + }, + "Cors": { + "Origins": [ + "http://localhost:5173", + "http://localhost:3000" + ] + }, + "Llm": { + "BaseUrl": "https://api.openai.com/v1/", + "ApiKey": "", + "Model": "gpt-4o-mini" + }, + "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" + } + } + } +} diff --git a/backend/UniVerse.sln b/backend/UniVerse.sln index 2196645..5b76058 100644 --- a/backend/UniVerse.sln +++ b/backend/UniVerse.sln @@ -1,6 +1,12 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse", "UniVerse\UniVerse.csproj", "{7D214ABB-8402-4FDD-9B88-D357F2A400C8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Api", "UniVerse.Api\UniVerse.Api.csproj", "{7D214ABB-8402-4FDD-9B88-D357F2A400C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Domain", "UniVerse.Domain\UniVerse.Domain.csproj", "{A1B2C3D4-1111-2222-3333-444455556666}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Application", "UniVerse.Application\UniVerse.Application.csproj", "{A1B2C3D4-1111-2222-3333-444455557777}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniVerse.Infrastructure", "UniVerse.Infrastructure\UniVerse.Infrastructure.csproj", "{A1B2C3D4-1111-2222-3333-444455558888}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -12,5 +18,17 @@ Global {7D214ABB-8402-4FDD-9B88-D357F2A400C8}.Debug|Any CPU.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 + {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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455557777}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1111-2222-3333-444455558888}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/backend/nuget.config b/backend/nuget.config new file mode 100644 index 0000000..6ce9759 --- /dev/null +++ b/backend/nuget.config @@ -0,0 +1,8 @@ + + + + + + + +