From 09d3d2778db96e32a0faff050a8fe99a167d1df4 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Sat, 30 May 2026 14:08:49 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D1=82=D1=8F=D0=BD?= =?UTF-8?q?=D1=83=D0=BB=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=D0=B7=20Modeus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/MappingExtensionsTests.cs | 3 +- .../Lectures/LectureServiceTests.cs | 23 + .../Sync/ModeusApiClientTests.cs | 62 + .../Sync/ScheduleSyncServiceTests.cs | 50 + backend/UniVerse.Api/Program.cs | 2 +- backend/UniVerse.Api/appsettings.json | 3 +- .../Interfaces/IScheduleSyncService.cs | 3 + .../Mappings/MappingExtensions.cs | 7 +- backend/UniVerse.Domain/Entities/Lecture.cs | 1 + .../Configurations/LectureConfiguration.cs | 1 + .../ExternalServices/ModeusApiClient.cs | 11 +- ...105758_MandatoryAttendeesCount.Designer.cs | 1149 +++++++++++++++++ .../20260530105758_MandatoryAttendeesCount.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 8 +- .../Services/LectureService.cs | 3 +- .../Services/ScheduleSyncService.cs | 42 +- 16 files changed, 1349 insertions(+), 48 deletions(-) create mode 100644 backend/UniVerse.Api.Tests/Sync/ModeusApiClientTests.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260530105758_MandatoryAttendeesCount.Designer.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260530105758_MandatoryAttendeesCount.cs diff --git a/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs index 138b38c..3a7d5bb 100644 --- a/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs +++ b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs @@ -53,6 +53,7 @@ public class MappingExtensionsTests EndsAt = startsAt.AddHours(2), IsOpen = true, MaxEnrollments = 25, + MandatoryAttendeesCount = 30, Enrollments = [ new LectureEnrollment { UserId = 1 }, @@ -66,7 +67,7 @@ public class MappingExtensionsTests Assert.Equal("", dto.CourseName); Assert.Null(dto.TeacherName); Assert.Null(dto.LocationName); - Assert.Equal(2, dto.EnrollmentsCount); + Assert.Equal(32, dto.EnrollmentsCount); Assert.True(dto.IsEnrolled); Assert.False(detail.IsEnrolled); } diff --git a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs index 9ed1966..b485d36 100644 --- a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs @@ -166,6 +166,29 @@ public class LectureServiceTests Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1)); } + [Fact] + public async Task EnrollAsync_CountsMandatoryAttendeesTowardLectureCapacity() + { + await using var db = CreateDbContext(); + var gamification = Substitute.For(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(1); + var service = new LectureService(db, gamification, Substitute.For()); + var lecture = Lecture(1, DateTime.UtcNow.AddDays(1)); + lecture.MaxEnrollments = 31; + lecture.MandatoryAttendeesCount = 30; + + db.Users.AddRange( + new User { Id = 1, Email = "first@test.local" }, + new User { Id = 2, Email = "second@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(lecture); + await db.SaveChangesAsync(); + + await service.EnrollAsync(1, 1); + + await Assert.ThrowsAsync(() => service.EnrollAsync(1, 2)); + } + [Fact] public async Task UnenrollAsync_CancelsLectureReminders() { diff --git a/backend/UniVerse.Api.Tests/Sync/ModeusApiClientTests.cs b/backend/UniVerse.Api.Tests/Sync/ModeusApiClientTests.cs new file mode 100644 index 0000000..36bd88e --- /dev/null +++ b/backend/UniVerse.Api.Tests/Sync/ModeusApiClientTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using UniVerse.Application.DTOs.Sync; +using UniVerse.Infrastructure.ExternalServices; +using Xunit; + +namespace UniVerse.Api.Tests.Sync; + +public class ModeusApiClientTests +{ + [Fact] + public async Task SearchEventsAsync_RequestsIctisEndpointWithCounts() + { + var handler = new CapturingHandler(); + var http = new HttpClient(handler) + { + BaseAddress = new Uri("https://schedule.test") + }; + var config = new ConfigurationBuilder().Build(); + var client = new ModeusApiClient(http, config, NullLogger.Instance); + + await client.SearchEventsAsync(new SyncScheduleRequest( + SpecialtyCode: ["09.03.04"], + TimeMin: new DateTime(2026, 4, 30, 21, 0, 0, DateTimeKind.Utc), + TimeMax: new DateTime(2026, 6, 13, 20, 59, 0, DateTimeKind.Utc), + TypeId: ["LECT"], + Size: 50)); + + Assert.Equal(HttpMethod.Post, handler.RequestMethod); + Assert.Equal("/api/ictis?includeCounts=true", handler.RequestPathAndQuery); + Assert.NotNull(handler.RequestBody); + using var body = JsonDocument.Parse(handler.RequestBody); + Assert.Equal(50, body.RootElement.GetProperty("size").GetInt32()); + Assert.Equal("09.03.04", body.RootElement.GetProperty("specialtyCode")[0].GetString()); + Assert.Equal("LECT", body.RootElement.GetProperty("typeId")[0].GetString()); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + public HttpMethod? RequestMethod { get; private set; } + public string? RequestPathAndQuery { get; private set; } + public string? RequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestMethod = request.Method; + RequestPathAndQuery = request.RequestUri?.PathAndQuery; + RequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"events":[]}""") + }; + } + } +} diff --git a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs index 0f7180b..f27bd07 100644 --- a/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Sync/ScheduleSyncServiceTests.cs @@ -129,6 +129,56 @@ public class ScheduleSyncServiceTests Assert.Equal(48, lecture.MaxEnrollments); } + [Fact] + public async Task SyncScheduleAsync_SavesMandatoryAttendeesFromIctisStats() + { + await using var db = CreateDbContext(); + var modeus = Substitute.For(); + modeus.SearchEventsAsync(Arg.Any()) + .Returns(new ModeusEventsResponse + { + Embedded = new ModeusEventsEmbedded + { + Events = + [ + new ModeusEvent + { + Id = "event-1", + Name = "Open lecture", + StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc), + EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc), + IctisStats = new ModeusIctisStats(StudentCount: 30, TeacherCount: 1) + } + ], + EventRooms = + [ + new ModeusEventRoom + { + Links = new ModeusEventRoomLinks + { + Event = new ModeusHrefLink("/events/event-1"), + Room = new ModeusHrefLink("/rooms/room-1") + } + } + ], + Rooms = + [ + new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 120, WorkingCapacity: 120) + ] + } + }); + + var service = new ScheduleSyncService(db, modeus, NullLogger.Instance); + + var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null)); + + var lecture = await db.Lectures.SingleAsync(); + Assert.Null(result.Error); + Assert.Equal(1, result.Created); + Assert.Equal(120, lecture.MaxEnrollments); + Assert.Equal(31, lecture.MandatoryAttendeesCount); + } + [Fact] public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher() { diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index e6a1089..400b497 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -136,7 +136,7 @@ builder.Services.AddHttpClient(client => builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru"); - client.Timeout = TimeSpan.FromSeconds(30); + client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180)); }); // --- Background Services --- diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json index ede48ed..6864a91 100644 --- a/backend/UniVerse.Api/appsettings.json +++ b/backend/UniVerse.Api/appsettings.json @@ -23,7 +23,8 @@ }, "ModeusApi": { "BaseUrl": "https://schedule.rdcenter.ru", - "ApiKey": "" + "ApiKey": "", + "TimeoutSeconds": 180 }, "Serilog": { "MinimumLevel": { diff --git a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs index dfad430..8561258 100644 --- a/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs +++ b/backend/UniVerse.Application/Interfaces/IScheduleSyncService.cs @@ -29,11 +29,14 @@ public class ModeusEvent public string? TypeId { get; init; } public DateTime StartsAt { get; init; } public DateTime EndsAt { get; init; } + public ModeusIctisStats? IctisStats { get; init; } [JsonPropertyName("_links")] public ModeusEventLinks? Links { get; init; } } +public record ModeusIctisStats(int? StudentCount, int? TeacherCount); + public class ModeusEventLinks { [JsonPropertyName("course-unit-realization")] diff --git a/backend/UniVerse.Application/Mappings/MappingExtensions.cs b/backend/UniVerse.Application/Mappings/MappingExtensions.cs index ddd978c..e332556 100644 --- a/backend/UniVerse.Application/Mappings/MappingExtensions.cs +++ b/backend/UniVerse.Application/Mappings/MappingExtensions.cs @@ -13,6 +13,9 @@ namespace UniVerse.Application.Mappings; public static class MappingExtensions { + private static int OccupiedSeatsCount(this Lecture lecture) => + Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count; + // --- User --- public static UserDto ToDto(this User user, int level) => new( user.Id, user.Email, user.DisplayName, user.AvatarUrl, @@ -57,7 +60,7 @@ public static class MappingExtensions lecture.LocationId, lecture.Location?.Name, lecture.Title, lecture.Description, lecture.Format, lecture.StartsAt, lecture.EndsAt, lecture.IsOpen, - lecture.MaxEnrollments, lecture.Enrollments.Count, + lecture.MaxEnrollments, lecture.OccupiedSeatsCount(), lecture.OnlineUrl, lecture.CreatedAt, isEnrolled ); @@ -67,7 +70,7 @@ public static class MappingExtensions lecture.LocationId, lecture.Location?.Name, lecture.Title, lecture.Description, lecture.Format, lecture.StartsAt, lecture.EndsAt, lecture.IsOpen, - lecture.MaxEnrollments, lecture.Enrollments.Count, + lecture.MaxEnrollments, lecture.OccupiedSeatsCount(), lecture.OnlineUrl, lecture.CreatedAt, isEnrolled ); diff --git a/backend/UniVerse.Domain/Entities/Lecture.cs b/backend/UniVerse.Domain/Entities/Lecture.cs index 37a732b..b4bc69d 100644 --- a/backend/UniVerse.Domain/Entities/Lecture.cs +++ b/backend/UniVerse.Domain/Entities/Lecture.cs @@ -15,6 +15,7 @@ public class Lecture public DateTime EndsAt { get; set; } public bool IsOpen { get; set; } = true; public int MaxEnrollments { get; set; } + public int MandatoryAttendeesCount { get; set; } public string? ExternalId { get; set; } public string? OnlineUrl { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs index f76d4c6..dda7c81 100644 --- a/backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs +++ b/backend/UniVerse.Infrastructure/Data/Configurations/LectureConfiguration.cs @@ -22,6 +22,7 @@ public class LectureConfiguration : IEntityTypeConfiguration builder.Property(l => l.EndsAt).HasColumnName("ends_at"); builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true); builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0); + builder.Property(l => l.MandatoryAttendeesCount).HasColumnName("mandatory_attendees_count").HasDefaultValue(0); builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255); builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500); builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); diff --git a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs index c55b711..3619b10 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/ModeusApiClient.cs @@ -42,12 +42,11 @@ public class ModeusApiClient : IModeusApiClient AddNonEmpty(body, "curriculumId", request.CurriculumId); AddNonEmpty(body, "typeId", request.TypeId); - var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body); var requestJson = JsonSerializer.Serialize(body); - await EnsureSuccessAsync(response, "Modeus events search", - BuildEventsRequestSummary(requestJson)); - return await ReadJsonAsync(response, "Modeus events search", - BuildEventsRequestSummary(requestJson)) + var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}"; + var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body); + await EnsureSuccessAsync(response, "ICTIS events search", requestSummary); + return await ReadJsonAsync(response, "ICTIS events search", requestSummary) ?? new ModeusEventsResponse(); } @@ -98,8 +97,6 @@ public class ModeusApiClient : IModeusApiClient response.StatusCode); } - private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}"; - private static void AddNonEmpty( IDictionary body, string key, diff --git a/backend/UniVerse.Infrastructure/Migrations/20260530105758_MandatoryAttendeesCount.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260530105758_MandatoryAttendeesCount.Designer.cs new file mode 100644 index 0000000..37fb09b --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260530105758_MandatoryAttendeesCount.Designer.cs @@ -0,0 +1,1149 @@ +// +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("20260530105758_MandatoryAttendeesCount")] + partial class MandatoryAttendeesCount + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .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("MandatoryAttendeesCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("mandatory_attendees_count"); + + 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/20260530105758_MandatoryAttendeesCount.cs b/backend/UniVerse.Infrastructure/Migrations/20260530105758_MandatoryAttendeesCount.cs new file mode 100644 index 0000000..c1ff902 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260530105758_MandatoryAttendeesCount.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class MandatoryAttendeesCount : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "mandatory_attendees_count", + table: "lectures", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "mandatory_attendees_count", + table: "lectures"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index f8a7d81..7604359 100644 --- a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" }); @@ -250,6 +250,12 @@ namespace UniVerse.Infrastructure.Migrations .HasColumnType("integer") .HasColumnName("location_id"); + b.Property("MandatoryAttendeesCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("mandatory_attendees_count"); + b.Property("MaxEnrollments") .ValueGeneratedOnAdd() .HasColumnType("integer") diff --git a/backend/UniVerse.Infrastructure/Services/LectureService.cs b/backend/UniVerse.Infrastructure/Services/LectureService.cs index aa49044..695ca4f 100644 --- a/backend/UniVerse.Infrastructure/Services/LectureService.cs +++ b/backend/UniVerse.Infrastructure/Services/LectureService.cs @@ -122,7 +122,8 @@ public class LectureService : ILectureService .FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId); var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId); if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment."); - if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments) + var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count; + if (lecture.MaxEnrollments > 0 && occupiedSeatsCount >= lecture.MaxEnrollments) throw new ConflictException("Lecture is full."); if (lecture.Enrollments.Any(e => e.UserId == userId)) throw new ConflictException("Already enrolled."); diff --git a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs index f5fb967..73f6a02 100644 --- a/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs +++ b/backend/UniVerse.Infrastructure/Services/ScheduleSyncService.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using System.Text.Json; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; @@ -55,6 +54,7 @@ public class ScheduleSyncService : IScheduleSyncService } var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0; + var mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats); var startsAt = EnsureUtc(ev.StartsAt); var endsAt = EnsureUtc(ev.EndsAt); @@ -68,6 +68,7 @@ public class ScheduleSyncService : IScheduleSyncService existing.LocationId = location?.Id; existing.TeacherId = teacher?.Id; existing.MaxEnrollments = lectureCapacity; + existing.MandatoryAttendeesCount = mandatoryAttendeesCount; existing.UpdatedAt = DateTime.UtcNow; updated++; } @@ -91,7 +92,8 @@ public class ScheduleSyncService : IScheduleSyncService ExternalId = ev.Id, StartsAt = startsAt, EndsAt = endsAt, - MaxEnrollments = lectureCapacity + MaxEnrollments = lectureCapacity, + MandatoryAttendeesCount = mandatoryAttendeesCount }); created++; } @@ -111,7 +113,7 @@ public class ScheduleSyncService : IScheduleSyncService updated, skipped, [ - $"requestJson={BuildScheduleRequestJson(request)}", + "endpoint=POST /api/ictis?includeCounts=true", $"timeMin={request.TimeMin:O}", $"timeMax={request.TimeMax:O}" ])); @@ -443,6 +445,9 @@ public class ScheduleSyncService : IScheduleSyncService private static int? NormalizeCapacity(int? capacity) => capacity is > 0 ? capacity : null; + private static int GetMandatoryAttendeesCount(ModeusIctisStats? stats) => + Math.Max(0, stats?.StudentCount ?? 0) + Math.Max(0, stats?.TeacherCount ?? 0); + private static string BuildModeusTeacherEmail(string personId) => $"modeus-{personId}@modeus.local".ToLowerInvariant(); @@ -488,37 +493,6 @@ public class ScheduleSyncService : IScheduleSyncService return details; } - private static string BuildScheduleRequestJson(SyncScheduleRequest request) - { - var body = new Dictionary - { - ["size"] = request.Size is > 0 ? request.Size.Value : 900, - ["timeMin"] = request.TimeMin, - ["timeMax"] = request.TimeMax - }; - - AddNonEmpty(body, "roomId", request.RoomId); - AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId); - AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId); - AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId); - AddNonEmpty(body, "specialtyCode", request.SpecialtyCode); - AddNonEmpty(body, "learningStartYear", request.LearningStartYear); - AddNonEmpty(body, "profileName", request.ProfileName); - AddNonEmpty(body, "curriculumId", request.CurriculumId); - AddNonEmpty(body, "typeId", request.TypeId); - - return JsonSerializer.Serialize(body); - } - - private static void AddNonEmpty( - IDictionary body, - string key, - IReadOnlyList? values) - { - if (values is { Count: > 0 }) - body[key] = values; - } - private static string? GetHrefId(string? href) { if (string.IsNullOrWhiteSpace(href))