diff --git a/.env.example b/.env.example index 5a8a3c3..e37b528 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,3 @@ EMAIL_SMTP_USERNAME= EMAIL_SMTP_PASSWORD= EMAIL_SMTP_FROM_ADDRESS=no-reply@universe.local EMAIL_SMTP_FROM_NAME=UniVerse - -# Gamification -GAMIFICATION_XP_THRESHOLDS=[0, 100, 300, 600, 1000, 1500, 2500, 4000] \ No newline at end of file diff --git a/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs b/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs index e256a21..d7f9974 100644 --- a/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Notifications; @@ -18,6 +17,7 @@ public class GamificationServiceTests public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce() { await using var db = CreateDbContext(); + SeedLevelThresholds(db); var service = CreateService(db); db.Users.Add(new User @@ -78,6 +78,7 @@ public class GamificationServiceTests public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears() { await using var db = CreateDbContext(); + SeedLevelThresholds(db); var service = CreateService(db); db.Users.Add(new User { Id = 1, Email = "student@test.local" }); @@ -98,6 +99,38 @@ public class GamificationServiceTests Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001)); } + [Theory] + [InlineData(0, 1)] + [InlineData(99, 1)] + [InlineData(100, 2)] + [InlineData(299, 2)] + [InlineData(300, 3)] + public async Task CalculateLevelAsync_UsesDatabaseThresholds(int xp, int expectedLevel) + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + var service = CreateService(db); + + var level = await service.CalculateLevelAsync(xp); + + Assert.Equal(expectedLevel, level); + } + + [Theory] + [InlineData(120, 100, 300)] + [InlineData(350, 300, null)] + public async Task GetLevelProgressAsync_ReturnsCurrentAndNextThresholds(int xp, int currentLevelXp, int? nextLevelXp) + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + var service = CreateService(db); + + var progress = await service.GetLevelProgressAsync(xp); + + Assert.Equal(currentLevelXp, progress.CurrentLevelXp); + Assert.Equal(nextLevelXp, progress.NextLevelXp); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -119,16 +152,16 @@ public class GamificationServiceTests notifications.SendAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Gamification:XpThresholds:0"] = "0", - ["Gamification:XpThresholds:1"] = "100", - ["Gamification:XpThresholds:2"] = "300" - }) - .Build(); + return new GamificationService(db, notifications, NullLogger.Instance); + } - return new GamificationService(db, configuration, notifications, NullLogger.Instance); + private static void SeedLevelThresholds(AppDbContext db) + { + db.LevelThresholds.AddRange( + new LevelThreshold { Level = 1, RequiredXp = 0 }, + new LevelThreshold { Level = 2, RequiredXp = 100 }, + new LevelThreshold { Level = 3, RequiredXp = 300 }); + db.SaveChanges(); } private static Achievement Achievement(int id, string name, string condition, int coinReward) => new() diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs index 90b0fef..1a19e1c 100644 --- a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -156,7 +156,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory stub.GetByIdAsync(Arg.Any()).Returns(userDto); stub.UpdateProfileAsync(Arg.Any(), Arg.Any()).Returns(userDto); - stub.GetStatsAsync(Arg.Any()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0)); + stub.GetStatsAsync(Arg.Any()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0, 0, 100)); stub.GetAllAsync(Arg.Any()).Returns(pagedUsers); stub.SetRolesAsync(Arg.Any(), Arg.Any>()).Returns(Task.CompletedTask); stub.SetActiveAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); @@ -274,7 +274,8 @@ public class ApiWebApplicationFactory : WebApplicationFactory stub.AwardCoinsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); stub.CheckAndAwardAchievementsAsync(Arg.Any()).Returns(Task.CompletedTask); - stub.CalculateLevel(Arg.Any()).Returns(1); + stub.CalculateLevelAsync(Arg.Any()).Returns(Task.FromResult(1)); + stub.GetLevelProgressAsync(Arg.Any()).Returns(Task.FromResult(new LevelProgressDto(0, 100))); return stub; } diff --git a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs new file mode 100644 index 0000000..aa82b6a --- /dev/null +++ b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Entities; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Users; + +public class UserServiceTests +{ + [Fact] + public async Task GetStatsAsync_ReturnsLevelProgressThresholds() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 120 }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var stats = await service.GetStatsAsync(1); + + Assert.Equal(2, stats.Level); + Assert.Equal(100, stats.CurrentLevelXp); + Assert.Equal(300, stats.NextLevelXp); + } + + [Fact] + public async Task GetStatsAsync_ReturnsNullNextLevelAtMaxConfiguredLevel() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var stats = await service.GetStatsAsync(1); + + Assert.Equal(3, stats.Level); + Assert.Equal(300, stats.CurrentLevelXp); + Assert.Null(stats.NextLevelXp); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"UserServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private static UserService CreateService(AppDbContext db) + { + var notifications = Substitute.For(); + notifications.CreateUserNotificationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt(1), callInfo.ArgAt(2), callInfo.ArgAt(3), false, DateTime.UtcNow)); + notifications.SendAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var gamification = new GamificationService(db, notifications, NullLogger.Instance); + return new UserService(db, gamification); + } + + private static void SeedLevelThresholds(AppDbContext db) + { + db.LevelThresholds.AddRange( + new LevelThreshold { Level = 1, RequiredXp = 0 }, + new LevelThreshold { Level = 2, RequiredXp = 100 }, + new LevelThreshold { Level = 3, RequiredXp = 300 }); + db.SaveChanges(); + } +} diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json index afa262e..85ca651 100644 --- a/backend/UniVerse.Api/appsettings.json +++ b/backend/UniVerse.Api/appsettings.json @@ -22,9 +22,6 @@ "BaseUrl": "https://schedule.rdcenter.ru", "ApiKey": "" }, - "Gamification": { - "XpThresholds": [0, 100, 300, 600, 1000, 1500, 2500, 4000] - }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index fd4ef0c..d9c8332 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -5624,6 +5624,15 @@ "achievementsCount": { "type": "integer", "format": "int32" + }, + "currentLevelXp": { + "type": "integer", + "format": "int32" + }, + "nextLevelXp": { + "type": "integer", + "format": "int32", + "nullable": true } }, "additionalProperties": false diff --git a/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs b/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs index 3248165..da1d58f 100644 --- a/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs +++ b/backend/UniVerse.Application/DTOs/Gamification/GamificationDtos.cs @@ -11,3 +11,8 @@ public record CoinTransactionDto( string? Description, DateTime CreatedAt ); + +public record LevelProgressDto( + int CurrentLevelXp, + int? NextLevelXp +); diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index f58bbb6..9628d92 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -22,7 +22,9 @@ public record UserStatsDto( int Xp, int Coins, int Level, - int AchievementsCount + int AchievementsCount, + int CurrentLevelXp, + int? NextLevelXp ); public record UpdateUserRequest( diff --git a/backend/UniVerse.Application/Interfaces/IGamificationService.cs b/backend/UniVerse.Application/Interfaces/IGamificationService.cs index 387b60c..57386e1 100644 --- a/backend/UniVerse.Application/Interfaces/IGamificationService.cs +++ b/backend/UniVerse.Application/Interfaces/IGamificationService.cs @@ -10,7 +10,8 @@ public interface IGamificationService Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type, int? reviewId = null, int? achievementId = null, string? description = null); Task CheckAndAwardAchievementsAsync(int userId); - int CalculateLevel(int xp); + Task CalculateLevelAsync(int xp); + Task GetLevelProgressAsync(int xp); Task> GetUserAchievementsAsync(int userId); Task> GetTransactionsAsync(int userId, PaginationRequest pagination); } diff --git a/backend/UniVerse.Domain/Entities/LevelThreshold.cs b/backend/UniVerse.Domain/Entities/LevelThreshold.cs new file mode 100644 index 0000000..53e46b0 --- /dev/null +++ b/backend/UniVerse.Domain/Entities/LevelThreshold.cs @@ -0,0 +1,7 @@ +namespace UniVerse.Domain.Entities; + +public class LevelThreshold +{ + public int Level { get; set; } + public int RequiredXp { get; set; } +} diff --git a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs index d322961..d580960 100644 --- a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs +++ b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs @@ -23,6 +23,7 @@ public class AppDbContext : DbContext public DbSet Achievements { get; set; } = null!; public DbSet UserAchievements { get; set; } = null!; public DbSet CoinTransactions { get; set; } = null!; + public DbSet LevelThresholds { get; set; } = null!; public DbSet UserNotifications { get; set; } = null!; public DbSet RefreshTokens { get; set; } = null!; diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/LevelThresholdConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/LevelThresholdConfiguration.cs new file mode 100644 index 0000000..bf72d44 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/LevelThresholdConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class LevelThresholdConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("level_thresholds", table => + { + table.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + table.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + builder.HasKey(t => t.Level); + builder.Property(t => t.Level).HasColumnName("level").ValueGeneratedNever(); + builder.Property(t => t.RequiredXp).HasColumnName("required_xp").IsRequired(); + builder.HasIndex(t => t.RequiredXp).IsUnique(); + + builder.HasData( + new LevelThreshold { Level = 1, RequiredXp = 0 }, + new LevelThreshold { Level = 2, RequiredXp = 100 }, + new LevelThreshold { Level = 3, RequiredXp = 300 }, + new LevelThreshold { Level = 4, RequiredXp = 600 }, + new LevelThreshold { Level = 5, RequiredXp = 1000 }, + new LevelThreshold { Level = 6, RequiredXp = 1500 }, + new LevelThreshold { Level = 7, RequiredXp = 2500 }, + new LevelThreshold { Level = 8, RequiredXp = 4000 } + ); + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.Designer.cs new file mode 100644 index 0000000..bf61fae --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.Designer.cs @@ -0,0 +1,1107 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260517230001_LevelThresholds")] + partial class LevelThresholds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_llm_status", "review_llm_status", new[] { "pending", "analyzed", "rejected" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_sentiment", "review_sentiment", new[] { "positive", "neutral", "negative" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tag_type", "tag_type", new[] { "institute", "faculty", "subject", "organization", "topic", "other" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_role", "user_role", new[] { "student", "teacher", "admin" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CoinReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coin_reward"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("XpReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("xp_reward"); + + b.HasKey("Id"); + + b.ToTable("achievements", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ReviewId") + .HasColumnType("integer") + .HasColumnName("review_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementId"); + + b.HasIndex("ReviewId"); + + b.HasIndex("UserId"); + + b.ToTable("coin_transactions", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("IsSynced") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_synced"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.ToTable("courses", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b => + { + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("TagId") + .HasColumnType("integer") + .HasColumnName("tag_id"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("CourseId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("CourseId", "TagId") + .IsUnique(); + + b.ToTable("course_tags", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("IsOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_open"); + + b.Property("LocationId") + .HasColumnType("integer") + .HasColumnName("location_id"); + + b.Property("MaxEnrollments") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("max_enrollments"); + + b.Property("OnlineUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("online_url"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("TeacherId") + .HasColumnType("integer") + .HasColumnName("teacher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.HasIndex("LocationId"); + + b.HasIndex("StartsAt"); + + b.HasIndex("TeacherId"); + + b.ToTable("lectures", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b => + { + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Attended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("attended"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("LectureId", "UserId"); + + b.HasIndex("UserId"); + + b.HasIndex("LectureId", "UserId") + .IsUnique(); + + b.ToTable("lecture_enrollments", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LevelThreshold", b => + { + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("RequiredXp") + .HasColumnType("integer") + .HasColumnName("required_xp"); + + b.HasKey("Level"); + + b.HasIndex("RequiredXp") + .IsUnique(); + + b.ToTable("level_thresholds", null, t => + { + t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + + t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + b.HasData( + new + { + Level = 1, + RequiredXp = 0 + }, + new + { + Level = 2, + RequiredXp = 100 + }, + new + { + Level = 3, + RequiredXp = 300 + }, + new + { + Level = 4, + RequiredXp = 600 + }, + new + { + Level = 5, + RequiredXp = 1000 + }, + new + { + Level = 6, + RequiredXp = 1500 + }, + new + { + Level = 7, + RequiredXp = 2500 + }, + new + { + Level = 8, + RequiredXp = 4000 + }); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("address"); + + b.Property("Building") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("building"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("Room") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("room"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.ToTable("locations", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsInformative") + .HasColumnType("boolean") + .HasColumnName("is_informative"); + + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("LlmStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("llm_status"); + + b.PrimitiveCollection("LlmTags") + .HasColumnType("text[]") + .HasColumnName("llm_tags"); + + b.Property("QualityScore") + .HasColumnType("double precision") + .HasColumnName("quality_score"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Sentiment") + .HasColumnType("integer") + .HasColumnName("sentiment"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("LlmStatus"); + + b.HasIndex("UserId"); + + b.HasIndex("LectureId", "UserId") + .IsUnique(); + + b.ToTable("reviews", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnrollmentYear") + .HasColumnType("integer") + .HasColumnName("enrollment_year"); + + b.Property("Faculty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("faculty"); + + b.Property("GroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("group_name"); + + b.Property("Specialty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("specialty"); + + b.Property("StudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("student_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("student_profiles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("integer") + .HasColumnName("parent_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("Department") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("department"); + + b.Property("ModeusId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("modeus_id"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("teacher_profiles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("Coins") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coins"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MicrosoftId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("microsoft_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Xp") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("xp"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("MicrosoftId") + .IsUnique() + .HasFilter("microsoft_id IS NOT NULL"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("AwardedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("UserId", "AchievementId"); + + b.HasIndex("AchievementId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("body"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("UserId", "Role"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b => + { + b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement") + .WithMany() + .HasForeignKey("AchievementId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.Review", "Review") + .WithMany("CoinTransactions") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("CoinTransactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Achievement"); + + b.Navigation("Review"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b => + { + b.HasOne("UniVerse.Domain.Entities.Course", "Course") + .WithMany("CourseTags") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.Tag", "Tag") + .WithMany("CourseTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.HasOne("UniVerse.Domain.Entities.Course", "Course") + .WithMany("Lectures") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.Location", "Location") + .WithMany("Lectures") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.User", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Course"); + + b.Navigation("Location"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b => + { + b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture") + .WithMany("Enrollments") + .HasForeignKey("LectureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lecture"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture") + .WithMany("Reviews") + .HasForeignKey("LectureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Reviews") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lecture"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithOne("StudentProfile") + .HasForeignKey("UniVerse.Domain.Entities.StudentProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.HasOne("UniVerse.Domain.Entities.Tag", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithOne("TeacherProfile") + .HasForeignKey("UniVerse.Domain.Entities.TeacherProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b => + { + b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement") + .WithMany("UserAchievements") + .HasForeignKey("AchievementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("UserAchievements") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Achievement"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b => + { + b.Navigation("UserAchievements"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Course", b => + { + b.Navigation("CourseTags"); + + b.Navigation("Lectures"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Navigation("Lectures"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.Navigation("CoinTransactions"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.Navigation("Children"); + + b.Navigation("CourseTags"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.User", b => + { + b.Navigation("CoinTransactions"); + + b.Navigation("Enrollments"); + + b.Navigation("Notifications"); + + b.Navigation("RefreshTokens"); + + b.Navigation("Reviews"); + + b.Navigation("Roles"); + + b.Navigation("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.cs b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.cs new file mode 100644 index 0000000..549ff6e --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260517230001_LevelThresholds.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class LevelThresholds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "level_thresholds", + columns: table => new + { + level = table.Column(type: "integer", nullable: false), + required_xp = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_level_thresholds", x => x.level); + table.CheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + table.CheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + migrationBuilder.InsertData( + table: "level_thresholds", + columns: new[] { "level", "required_xp" }, + values: new object[,] + { + { 1, 0 }, + { 2, 100 }, + { 3, 300 }, + { 4, 600 }, + { 5, 1000 }, + { 6, 1500 }, + { 7, 2500 }, + { 8, 4000 } + }); + + migrationBuilder.CreateIndex( + name: "IX_level_thresholds_required_xp", + table: "level_thresholds", + column: "required_xp", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "level_thresholds"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 5c0db76..a8175b3 100644 --- a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -334,6 +334,71 @@ namespace UniVerse.Infrastructure.Migrations b.ToTable("lecture_enrollments", (string)null); }); + modelBuilder.Entity("UniVerse.Domain.Entities.LevelThreshold", b => + { + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("RequiredXp") + .HasColumnType("integer") + .HasColumnName("required_xp"); + + b.HasKey("Level"); + + b.HasIndex("RequiredXp") + .IsUnique(); + + b.ToTable("level_thresholds", null, t => + { + t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + + t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + b.HasData( + new + { + Level = 1, + RequiredXp = 0 + }, + new + { + Level = 2, + RequiredXp = 100 + }, + new + { + Level = 3, + RequiredXp = 300 + }, + new + { + Level = 4, + RequiredXp = 600 + }, + new + { + Level = 5, + RequiredXp = 1000 + }, + new + { + Level = 6, + RequiredXp = 1500 + }, + new + { + Level = 7, + RequiredXp = 2500 + }, + new + { + Level = 8, + RequiredXp = 4000 + }); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => { b.Property("Id") diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 4b0aa58..a48c5bb 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -221,7 +221,7 @@ public class AuthService : IAuthService .Include(u => u.Roles) .FirstOrDefaultAsync(u => u.Id == userId) ?? throw new NotFoundException("User", userId); - return user.ToDto(_gamification.CalculateLevel(user.Xp)); + return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)); } private async Task TrySendLoginNotificationAsync(User user, string? ipAddress) diff --git a/backend/UniVerse.Infrastructure/Services/GamificationService.cs b/backend/UniVerse.Infrastructure/Services/GamificationService.cs index 79dbd52..3342b43 100644 --- a/backend/UniVerse.Infrastructure/Services/GamificationService.cs +++ b/backend/UniVerse.Infrastructure/Services/GamificationService.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Globalization; using UniVerse.Application.DTOs.Achievements; @@ -17,17 +16,16 @@ namespace UniVerse.Infrastructure.Services; public class GamificationService : IGamificationService { private readonly AppDbContext _db; - private readonly IConfiguration _config; private readonly INotificationService _notifications; private readonly ILogger _logger; + private List? _levelThresholds; public GamificationService( AppDbContext db, - IConfiguration config, INotificationService notifications, ILogger logger) { - _db = db; _config = config; _notifications = notifications; _logger = logger; + _db = db; _notifications = notifications; _logger = logger; } public async Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type, @@ -83,7 +81,7 @@ public class GamificationService : IGamificationService "attendance_streak_weeks" => attendanceStreakWeeks >= value, "attended_registered" => attended >= value, "coins_earned" => earnedCoins >= value, - "level_reached" => CalculateLevel(user.Xp) >= value, + "level_reached" => await CalculateLevelAsync(user.Xp) >= value, "profile_completed" => profileCompleted && value <= 1, "first_activity" => firstActivity && value <= 1, _ => false @@ -184,13 +182,47 @@ public class GamificationService : IGamificationService return DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday)); } - public int CalculateLevel(int xp) + public async Task CalculateLevelAsync(int xp) { - var thresholds = _config.GetSection("Gamification:XpThresholds").Get() - ?? [0, 100, 300, 600, 1000, 1500, 2500, 4000]; - for (int i = thresholds.Length - 1; i >= 0; i--) - if (xp >= thresholds[i]) return i + 1; - return 1; + var thresholds = await GetLevelThresholdsAsync(); + return thresholds + .Where(t => xp >= t.RequiredXp) + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .LastOrDefault()?.Level ?? thresholds[0].Level; + } + + public async Task GetLevelProgressAsync(int xp) + { + var thresholds = await GetLevelThresholdsAsync(); + var current = thresholds + .Where(t => xp >= t.RequiredXp) + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .LastOrDefault() ?? thresholds[0]; + var next = thresholds + .Where(t => t.RequiredXp > current.RequiredXp) + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .FirstOrDefault(); + + return new LevelProgressDto(current.RequiredXp, next?.RequiredXp); + } + + private async Task> GetLevelThresholdsAsync() + { + if (_levelThresholds is { Count: > 0 }) return _levelThresholds; + + _levelThresholds = await _db.LevelThresholds + .AsNoTracking() + .OrderBy(t => t.RequiredXp) + .ThenBy(t => t.Level) + .ToListAsync(); + + if (_levelThresholds.Count == 0) + _levelThresholds.Add(new LevelThreshold { Level = 1, RequiredXp = 0 }); + + return _levelThresholds; } public async Task> GetUserAchievementsAsync(int userId) => diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index 9d67158..c563642 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -26,7 +26,7 @@ public class UserService : IUserService .Include(u => u.Roles) .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); - return user.ToDto(_gamification.CalculateLevel(user.Xp)); + return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)); } public async Task UpdateProfileAsync(int id, UpdateUserRequest request) @@ -42,7 +42,7 @@ public class UserService : IUserService await _db.SaveChangesAsync(); await _gamification.CheckAndAwardAchievementsAsync(id); - return user.ToDto(_gamification.CalculateLevel(user.Xp)); + return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)); } public async Task GetStatsAsync(int id) @@ -55,9 +55,13 @@ public class UserService : IUserService var reviews = await _db.Reviews.CountAsync(r => r.UserId == id); var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id); + var level = await _gamification.CalculateLevelAsync(user.Xp); + var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp); + return new UserStatsDto( totalLectures, attended, reviews, - user.Xp, user.Coins, _gamification.CalculateLevel(user.Xp), achievements + user.Xp, user.Coins, level, achievements, + levelProgress.CurrentLevelXp, levelProgress.NextLevelXp ); } @@ -94,7 +98,10 @@ public class UserService : IUserService .Take(filter.PageSize) .ToListAsync(); - var items = users.Select(u => u.ToDto(_gamification.CalculateLevel(u.Xp))).ToList(); + var items = new List(users.Count); + foreach (var user in users) + items.Add(user.ToDto(await _gamification.CalculateLevelAsync(user.Xp))); + return PagedResult.Create(items, total, filter.Page, filter.PageSize); } diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 7856e49..b6ec100 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -41,8 +41,6 @@ services: - Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local} - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} - - Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]} - - ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} networks: - backend diff --git a/docker-compose-test.yml b/docker-compose-test.yml index e939f2a..567d6a4 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -42,9 +42,6 @@ services: - Email:Smtp:Password=${EMAIL_SMTP_PASSWORD} - Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local} - Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse} - - - Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]} - - ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} db: diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index fdc00db..7c54682 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -45,6 +45,8 @@ export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): U coins: stats?.coins ?? ('coins' in user ? user.coins : 0), level: stats?.level ?? ('level' in user ? user.level : 1), xp: stats?.xp ?? ('xp' in user ? user.xp : 0), + currentLevelXp: stats?.currentLevelXp ?? 0, + nextLevelXp: stats?.nextLevelXp, lecturesAttended: stats?.attendedLectures ?? 0, hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0, achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [], diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 5ecbf16..940cc74 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -60,6 +60,8 @@ export interface UserStatsDto { coins: number level: number achievementsCount: number + currentLevelXp: number + nextLevelXp?: number | null } export interface LectureDto { diff --git a/frontend/src/components/ui/ProgressBar.vue b/frontend/src/components/ui/ProgressBar.vue index ae0d67c..a5c2f61 100644 --- a/frontend/src/components/ui/ProgressBar.vue +++ b/frontend/src/components/ui/ProgressBar.vue @@ -4,6 +4,7 @@ defineProps<{ max?: number label?: string color?: string + text?: string }>() @@ -14,12 +15,12 @@ defineProps<{
-
{{ value }} / {{ max ?? 100 }}
+
{{ text ?? `${value} / ${max ?? 100}` }}
diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 6636fce..f09b80b 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -36,6 +36,8 @@ export const useUserStore = defineStore('user', () => { coins: stats.coins, level: stats.level, xp: stats.xp, + currentLevelXp: stats.currentLevelXp, + nextLevelXp: stats.nextLevelXp, lecturesAttended: stats.attendedLectures, hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10, achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 331d94a..79b0b9e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -14,6 +14,8 @@ export interface User { coins: number level: number xp?: number + currentLevelXp?: number + nextLevelXp?: number | null lecturesAttended?: number hoursLearned?: number achievements?: string[] diff --git a/frontend/src/views/student/DashboardView.vue b/frontend/src/views/student/DashboardView.vue index 643a0ba..996261d 100644 --- a/frontend/src/views/student/DashboardView.vue +++ b/frontend/src/views/student/DashboardView.vue @@ -33,8 +33,25 @@ const recommended = computed(() => ) const achievements = computed(() => userStore.achievements.filter(a => a.unlocked).slice(0, 3)) const reminders = computed(() => userStore.notifications.slice(0, 3)) -const xpToNext = 200 -const xpProgress = computed(() => user.value.xp ?? 120) +const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0) +const nextLevelXp = computed(() => user.value.nextLevelXp) +const userXp = computed(() => user.value.xp ?? 0) +const hasLevelProgress = computed(() => nextLevelXp.value !== undefined) +const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value) +const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1) +const levelProgress = computed(() => { + if (!hasLevelProgress.value) return 0 + if (!hasNextLevel.value) return 1 + return Math.min(Math.max(userXp.value - currentLevelXp.value, 0), levelProgressMax.value) +}) +const levelProgressLabel = computed(() => + !hasLevelProgress.value + ? `Уровень ${user.value.level}` + : hasNextLevel.value ? `Прогресс до уровня ${user.value.level + 1}` : 'Максимальный уровень' +) +const levelProgressText = computed(() => + hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP` +) onMounted(async () => { await Promise.all([ @@ -99,10 +116,10 @@ onMounted(async () => {
- Прогресс до уровня {{ user.level + 1 }} - {{ xpProgress }} / {{ xpToNext }} XP + {{ levelProgressLabel }} + {{ levelProgressText }}
- +
diff --git a/frontend/src/views/student/ProfileView.vue b/frontend/src/views/student/ProfileView.vue index 592008d..ffd3018 100644 --- a/frontend/src/views/student/ProfileView.vue +++ b/frontend/src/views/student/ProfileView.vue @@ -24,6 +24,25 @@ const userYearLine = computed(() => { const year = user.value.year ?? 0 return Number.isFinite(year) && year > 0 ? `${year} курс` : '' }) +const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0) +const nextLevelXp = computed(() => user.value.nextLevelXp) +const userXp = computed(() => user.value.xp ?? 0) +const hasLevelProgress = computed(() => nextLevelXp.value !== undefined) +const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value) +const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1) +const levelProgress = computed(() => { + if (!hasLevelProgress.value) return 0 + if (!hasNextLevel.value) return 1 + return Math.min(Math.max(userXp.value - currentLevelXp.value, 0), levelProgressMax.value) +}) +const levelProgressLabel = computed(() => + !hasLevelProgress.value + ? `Уровень ${user.value.level}` + : hasNextLevel.value ? `Уровень ${user.value.level}` : `Уровень ${user.value.level} · максимум` +) +const levelProgressText = computed(() => + hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP` +) const interestTags = ref([ { label: '#ML', active: true }, { label: '#ИИ', active: true }, @@ -66,10 +85,10 @@ onMounted(() => {
- Уровень {{ user.level }} - {{ user.xp }} / 200 XP + {{ levelProgressLabel }} + {{ levelProgressText }}
- +
Интересы