From 99d25adbb1fbeb1113348f53974517777dc838aa Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Sun, 24 May 2026 21:06:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D1=82=D0=B8=D0=BB=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/AuthServiceTests.cs | 49 +- .../Helpers/ApiWebApplicationFactory.cs | 13 +- .../Lectures/LectureServiceTests.cs | 33 + .../Reviews/ReviewServiceTests.cs | 32 +- .../Sync/ScheduleSyncServiceTests.cs | 169 ++- .../Controllers/LecturesController.cs | 9 +- .../Controllers/UsersController.cs | 1 + backend/UniVerse.Api/Program.cs | 1 + backend/UniVerse.Api/openapi.json | 8 + .../DTOs/Auth/AuthDtos.cs | 2 +- .../DTOs/Users/UserDtos.cs | 1 + .../Interfaces/ILectureService.cs | 6 +- .../Interfaces/IMicrosoftAuthClient.cs | 11 + .../Interfaces/IReviewService.cs | 2 +- .../Interfaces/IScheduleSyncService.cs | 1 + .../Mappings/MappingExtensions.cs | 4 +- .../TeacherProfileConfiguration.cs | 1 + .../ExternalServices/MicrosoftAuthClient.cs | 52 + .../ExternalServices/ModeusApiClient.cs | 18 + ...5_UniqueTeacherProfileModeusId.Designer.cs | 1143 +++++++++++++++++ ...0524173815_UniqueTeacherProfileModeusId.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 12 +- .../Services/AuthService.cs | 49 +- .../Services/LectureService.cs | 25 +- .../Services/ReviewService.cs | 17 +- .../Services/ScheduleSyncService.cs | 100 ++ frontend/src/api/mappers.ts | 6 +- frontend/src/api/types.ts | 1 + frontend/src/stores/lectures.ts | 5 +- frontend/src/types/index.ts | 2 + .../views/teacher/TeacherAnalyticsView.vue | 14 +- .../views/teacher/TeacherDashboardView.vue | 15 +- .../src/views/teacher/TeacherLecturesView.vue | 15 +- 33 files changed, 1756 insertions(+), 90 deletions(-) create mode 100644 backend/UniVerse.Application/Interfaces/IMicrosoftAuthClient.cs create mode 100644 backend/UniVerse.Infrastructure/ExternalServices/MicrosoftAuthClient.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.Designer.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260524173815_UniqueTeacherProfileModeusId.cs diff --git a/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs index b62a111..f81fa63 100644 --- a/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Auth/AuthServiceTests.cs @@ -2,6 +2,8 @@ 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; @@ -60,6 +62,37 @@ public class AuthServiceTests 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() @@ -68,7 +101,7 @@ public class AuthServiceTests return new AppDbContext(options); } - private static AuthService CreateService(AppDbContext db) + private static AuthService CreateService(AppDbContext db, IMicrosoftAuthClient? microsoftAuth = null) { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -88,6 +121,18 @@ public class AuthServiceTests notifications.SendAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); - return new AuthService(db, config, gamification, notifications, NullLogger.Instance); + 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/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs index 9ce90d0..0367cfc 100644 --- a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -98,6 +98,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory ReplaceWithSubstitute(services, CreateSyncServiceStub()); ReplaceWithSubstitute(services, Substitute.For()); ReplaceWithSubstitute(services, Substitute.For()); + ReplaceWithSubstitute(services, Substitute.For()); ReplaceWithSubstitute(services, CreateNotificationServiceStub()); }); } @@ -116,7 +117,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory var stub = Substitute.For(); var authResult = new AuthResult( new AuthResponse("access_token", DateTime.UtcNow.AddHours(1), - new UserAuthDto("test@test.com", "Test User", [UserRole.Student])), + new UserAuthDto(1, "test@test.com", "Test User", [UserRole.Student])), "refresh_token"); stub.LoginWithMicrosoftAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(authResult); @@ -124,7 +125,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory .Returns(authResult); stub.RefreshTokenAsync(Arg.Any()).Returns(authResult); stub.GetCurrentUserAsync(Arg.Any()) - .Returns(new CurrentUserDto("test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow)); + .Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow)); return stub; } @@ -199,12 +200,12 @@ public class ApiWebApplicationFactory : WebApplicationFactory 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()).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()).Returns(Task.CompletedTask); - stub.GetEnrollmentsAsync(Arg.Any(), Arg.Any()).Returns(pagedEnrollments); + 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; } @@ -220,7 +221,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory 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()).Returns(pagedReviews); + 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); diff --git a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs index 3d170ff..9ed1966 100644 --- a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs @@ -187,6 +187,39 @@ public class LectureServiceTests 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() diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs index 1e1e633..ac58468 100644 --- a/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs @@ -1,9 +1,11 @@ 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; @@ -65,6 +67,29 @@ public class ReviewServiceTests 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(); @@ -72,7 +97,7 @@ public class ReviewServiceTests return new ReviewService(db, gamification, queue); } - private static async Task SeedLectureAsync(AppDbContext db) + 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" }); @@ -80,6 +105,7 @@ public class ReviewServiceTests { Id = 1, CourseId = 1, + TeacherId = teacherId, Title = "Lecture", StartsAt = DateTime.UtcNow.AddDays(-1), EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2), @@ -89,9 +115,9 @@ public class ReviewServiceTests await db.SaveChangesAsync(); } - private static async Task SeedAnalyzedReviewAsync(AppDbContext db) + private static async Task SeedAnalyzedReviewAsync(AppDbContext db, int? teacherId = null) { - await SeedLectureAsync(db); + await SeedLectureAsync(db, teacherId); db.Reviews.Add(new Review { Id = 1, diff --git a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs index 35589be..0f7180b 100644 --- a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs @@ -12,6 +12,11 @@ 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() { @@ -149,6 +154,138 @@ public class ScheduleSyncServiceTests 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() @@ -160,10 +297,7 @@ public class ScheduleSyncServiceTests private static ModeusEventsResponse BuildEventsResponse() { - const string eventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a"; - const string courseId = "73aa6226-adbb-4e15-b264-e16fee19fd73"; const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7"; - const string personId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440"; return new ModeusEventsResponse { @@ -173,25 +307,25 @@ public class ScheduleSyncServiceTests [ new ModeusEvent { - Id = eventId, + 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}") + CourseUnitRealization = new ModeusHrefLink($"/{CourseId}") } } ], CourseUnitRealizations = [ new ModeusCourseUnitRealization( - courseId, + CourseId, "Управление проектами разработки программного обеспечения", "УПРПО") ], - EventTeams = [new ModeusEventTeam(eventId, 25)], + EventTeams = [new ModeusEventTeam(EventId, 25)], EventAttendees = [ new ModeusEventAttendee @@ -201,30 +335,41 @@ public class ScheduleSyncServiceTests RoleName = "Преподаватель", Links = new ModeusEventAttendeeLinks { - Event = new ModeusHrefLink($"/{eventId}"), - Person = new ModeusHrefLink($"/{personId}") + Event = new ModeusHrefLink($"/{EventId}"), + Person = new ModeusHrefLink($"/{PersonId}") } } ], Persons = [ new ModeusPerson( - personId, + PersonId, "Иванов", "Иван", "Иванович", - "Иванов Иван Иванович") + FullName) ] } }; } - private sealed class FakeModeusApiClient(ModeusEventsResponse events) : IModeusApiClient + 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/Controllers/LecturesController.cs b/backend/UniVerse.Api/Controllers/LecturesController.cs index e8237b8..a500870 100644 --- a/backend/UniVerse.Api/Controllers/LecturesController.cs +++ b/backend/UniVerse.Api/Controllers/LecturesController.cs @@ -25,6 +25,7 @@ public class LecturesController : ControllerBase private int CurrentUserId => int.Parse( User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); + private bool CurrentUserIsAdmin => User.IsInRole("Admin"); /// Получить каталог лекций с фильтрацией и пагинацией. /// @@ -84,7 +85,7 @@ public class LecturesController : ControllerBase [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. Каскадно удаляет записи и отзывы. @@ -168,7 +169,7 @@ public class LecturesController : ControllerBase [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Attendance(int id, int userId, [FromBody] bool attended) { - await _lectures.MarkAttendanceAsync(id, userId, attended); + await _lectures.MarkAttendanceAsync(id, userId, attended, CurrentUserId, CurrentUserIsAdmin); return NoContent(); } @@ -187,7 +188,7 @@ public class LecturesController : ControllerBase [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. @@ -204,5 +205,5 @@ public class LecturesController : ControllerBase [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/UsersController.cs b/backend/UniVerse.Api/Controllers/UsersController.cs index af100fe..6a15161 100644 --- a/backend/UniVerse.Api/Controllers/UsersController.cs +++ b/backend/UniVerse.Api/Controllers/UsersController.cs @@ -27,6 +27,7 @@ public class UsersController : ControllerBase 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, diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 8475d02..a9d76c9 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -95,6 +95,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index 4f2b60f..5de3dc1 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -5063,6 +5063,10 @@ "CurrentUserDto": { "type": "object", "properties": { + "id": { + "type": "integer", + "format": "int32" + }, "email": { "type": "string", "nullable": true @@ -6010,6 +6014,10 @@ "UserAuthDto": { "type": "object", "properties": { + "id": { + "type": "integer", + "format": "int32" + }, "email": { "type": "string", "nullable": true diff --git a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs index e2b17e7..e2519e3 100644 --- a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs +++ b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs @@ -5,7 +5,7 @@ namespace UniVerse.Application.DTOs.Auth; public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User); public record AuthResult(AuthResponse Response, string RefreshToken); -public record UserAuthDto(string Email, string? DisplayName, IReadOnlyList Roles); +public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList Roles); public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null); diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index 328a3b2..c06797f 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -16,6 +16,7 @@ public record UserDto( ); public record CurrentUserDto( + int Id, string Email, string? DisplayName, string? AvatarUrl, diff --git a/backend/UniVerse.Application/Interfaces/ILectureService.cs b/backend/UniVerse.Application/Interfaces/ILectureService.cs index a7e8228..75d12a3 100644 --- a/backend/UniVerse.Application/Interfaces/ILectureService.cs +++ b/backend/UniVerse.Application/Interfaces/ILectureService.cs @@ -8,10 +8,10 @@ public interface ILectureService 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/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/IReviewService.cs b/backend/UniVerse.Application/Interfaces/IReviewService.cs index 3399edc..38c1823 100644 --- a/backend/UniVerse.Application/Interfaces/IReviewService.cs +++ b/backend/UniVerse.Application/Interfaces/IReviewService.cs @@ -9,7 +9,7 @@ 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> GetAllAsync(ReviewFilterRequest filter); Task ReanalyzeAsync(int id); diff --git a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs index e4f4cf2..dfad430 100644 --- a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs +++ b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs @@ -16,6 +16,7 @@ public interface IModeusApiClient Task SearchEventsAsync(SyncScheduleRequest request); Task SearchRoomsAsync(); Task> SearchEmployeeAsync(string fullname); + Task GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default); } // Modeus API response models diff --git a/backend/UniVerse.Application/Mappings/MappingExtensions.cs b/backend/UniVerse.Application/Mappings/MappingExtensions.cs index b41574c..ddd978c 100644 --- a/backend/UniVerse.Application/Mappings/MappingExtensions.cs +++ b/backend/UniVerse.Application/Mappings/MappingExtensions.cs @@ -20,12 +20,12 @@ public static class MappingExtensions ); public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new( - user.Email, user.DisplayName, user.AvatarUrl, + 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.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList() + user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList() ); // --- Tag --- 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/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 7ee438a..230d046 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs @@ -1,4 +1,6 @@ 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; @@ -142,4 +144,20 @@ public class ModeusApiClient : IModeusApiClient $"/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/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/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 96fd50d..f8a7d81 100644 --- a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -515,16 +515,16 @@ namespace UniVerse.Infrastructure.Migrations .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.Property("LlmRawOutput") - .HasColumnType("text") - .HasColumnName("llm_raw_output"); - b.PrimitiveCollection("LlmTags") .HasColumnType("text[]") .HasColumnName("llm_tags"); @@ -710,6 +710,10 @@ namespace UniVerse.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("ModeusId") + .IsUnique() + .HasFilter("modeus_id IS NOT NULL"); + b.HasIndex("UserId") .IsUnique(); diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 3bf5595..679e58f 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -1,4 +1,3 @@ -using Microsoft.Identity.Client; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; @@ -23,6 +22,7 @@ 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; @@ -30,12 +30,14 @@ public class AuthService : IAuthService 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; @@ -43,36 +45,10 @@ public class AuthService : IAuthService 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 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 effectiveRedirectUri = redirectUri ?? _config["AzureAd:RedirectUri"] ?? "http://localhost:5173/auth/callback"; - - var authority = $"{instance.TrimEnd('/')}/{tenantId}"; - - var app = ConfidentialClientApplicationBuilder.Create(clientId) - .WithClientSecret(clientSecret) - .WithAuthority(new Uri(authority)) - .WithRedirectUri(effectiveRedirectUri) - .Build(); - - AuthenticationResult result; - try - { - result = await app.AcquireTokenByAuthorizationCode(new[] { "User.Read" }, authorizationCode) - .ExecuteAsync(); - } - catch (MsalException ex) - { - throw new UnauthorizedException($"Ошибка аутентификации Microsoft: {ex.Message}"); - } + var result = await _microsoftAuth.ExchangeAuthorizationCodeAsync(authorizationCode, effectiveRedirectUri); // Parse claims directly from the ID token provided by Microsoft var handler = new JwtSecurityTokenHandler(); @@ -80,13 +56,21 @@ 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 не найден в токене Microsoft."); + if (string.IsNullOrWhiteSpace(microsoftSub)) + throw new UnauthorizedException("Sub ID не найден в токене Microsoft."); // Automatically provision user 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) { @@ -94,6 +78,7 @@ public class AuthService : IAuthService { Email = email, DisplayName = name ?? email.Split('@')[0], + MicrosoftId = microsoftSub, IsActive = true }; _db.Users.Add(user); @@ -107,6 +92,14 @@ public class AuthService : IAuthService { 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) { diff --git a/backend/UniVerse.Infrastructure/Services/LectureService.cs b/backend/UniVerse.Infrastructure/Services/LectureService.cs index dc80784..aa49044 100644 --- a/backend/UniVerse.Infrastructure/Services/LectureService.cs +++ b/backend/UniVerse.Infrastructure/Services/LectureService.cs @@ -82,13 +82,14 @@ 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 .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; @@ -150,8 +151,9 @@ public class LectureService : ILectureService 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."); @@ -161,8 +163,9 @@ public class LectureService : ILectureService 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(); @@ -171,6 +174,22 @@ public class LectureService : ILectureService 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) diff --git a/backend/UniVerse.Infrastructure/Services/ReviewService.cs b/backend/UniVerse.Infrastructure/Services/ReviewService.cs index c850ef1..eecc608 100644 --- a/backend/UniVerse.Infrastructure/Services/ReviewService.cs +++ b/backend/UniVerse.Infrastructure/Services/ReviewService.cs @@ -75,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) diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index 3f86252..f568c15 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -218,15 +218,42 @@ public class ScheduleSyncService : IScheduleSyncService .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) @@ -239,6 +266,7 @@ public class ScheduleSyncService : IScheduleSyncService { Email = email, DisplayName = fullName, + MicrosoftId = subId, IsActive = true, TeacherProfile = new TeacherProfile { ModeusId = personId } }; @@ -249,6 +277,8 @@ public class ScheduleSyncService : IScheduleSyncService } 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 }; @@ -261,6 +291,76 @@ public class ScheduleSyncService : IScheduleSyncService 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)) diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 534049f..334c997 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -24,15 +24,16 @@ function mapApiRoles(roles: string[] | undefined): UserRole[] { } function getDefaultActiveRole(roles: UserRole[]): UserRole { - if (roles.includes('student')) return 'student' - if (roles.includes('teacher')) return 'teacher' 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, @@ -68,6 +69,7 @@ export function mapApiLecture(lecture: LectureDto): Lecture { return { id: String(lecture.id), + teacherId: lecture.teacherId, title: lecture.title || lecture.courseName || 'Лекция без названия', description: lecture.description || 'Описание появится позже.', teacher: lecture.teacherName || 'Преподаватель уточняется', diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index afebc53..0487f23 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -29,6 +29,7 @@ export interface LoginMicrosoftRequest { } export interface UserAuthDto { + id: number email: string displayName?: string | null roles: ApiUserRole[] diff --git a/frontend/src/stores/lectures.ts b/frontend/src/stores/lectures.ts index 4dd298a..e8f2e3e 100644 --- a/frontend/src/stores/lectures.ts +++ b/frontend/src/stores/lectures.ts @@ -3,6 +3,7 @@ import { computed, ref } from 'vue' import { lecturesApi, usersApi } from '@/api' import { mapApiLecture, mapApiReview } from '@/api/mappers' import type { Lecture, Review } from '@/types' +import type { LectureQuery } from '@/api/types' import { useUserStore } from './user' export const useLecturesStore = defineStore('lectures', () => { @@ -18,11 +19,11 @@ export const useLecturesStore = defineStore('lectures', () => { lectures.value.filter(l => registered.value.includes(l.id) || l.registered), ) - async function fetchLectures() { + async function fetchLectures(query: LectureQuery = {}) { loading.value = true error.value = null try { - const payload = await lecturesApi.list({ PageSize: 100 }) + 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) { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e7bc1f7..d69583e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,6 +1,7 @@ export type UserRole = 'student' | 'teacher' | 'admin' export interface User { + id: number name: string email: string roles: UserRole[] @@ -30,6 +31,7 @@ export interface EnrollmentSlotRule { export interface Lecture { id: string + teacherId?: number | null title: string description: string teacher: string diff --git a/frontend/src/views/teacher/TeacherAnalyticsView.vue b/frontend/src/views/teacher/TeacherAnalyticsView.vue index 8276e19..53f6283 100644 --- a/frontend/src/views/teacher/TeacherAnalyticsView.vue +++ b/frontend/src/views/teacher/TeacherAnalyticsView.vue @@ -1,5 +1,5 @@