diff --git a/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs b/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs new file mode 100644 index 0000000..6afdd94 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Gamification/GamificationServiceTests.cs @@ -0,0 +1,136 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Gamification; + +public class GamificationServiceTests +{ + [Fact] + public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce() + { + await using var db = CreateDbContext(); + var service = CreateService(db); + + db.Users.Add(new User + { + Id = 1, + Email = "student@test.local", + DisplayName = "Student", + AvatarUrl = "avatar.png", + Xp = 100, + Coins = 510 + }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(new Lecture + { + Id = 1, + CourseId = 1, + Title = "Future lecture", + StartsAt = DateTime.UtcNow.AddDays(1), + EndsAt = DateTime.UtcNow.AddDays(1).AddHours(2), + IsOpen = true + }); + db.LectureEnrollments.Add(new LectureEnrollment { UserId = 1, LectureId = 1 }); + db.Reviews.AddRange( + new Review { Id = 1, UserId = 1, LectureId = 1, Rating = ReviewRating.Like }, + new Review { Id = 2, UserId = 1, LectureId = 1, Rating = ReviewRating.Neutral }, + new Review { Id = 3, UserId = 1, LectureId = 1, Rating = ReviewRating.Dislike }); + db.CoinTransactions.Add(new CoinTransaction + { + UserId = 1, + Amount = 510, + Type = CoinTransactionType.AdminAdjustment, + Description = "Initial coins" + }); + db.Achievements.AddRange( + Achievement(1001, "First activity", "first_activity:1", 10), + Achievement(1002, "Reviews", "reviews_written:3", 20), + Achievement(1003, "Active registrations", "active_registrations:1", 30), + Achievement(1004, "Coins earned", "coins_earned:500", 40), + Achievement(1005, "Level reached", "level_reached:2", 50), + Achievement(1006, "Profile completed", "profile_completed:1", 60), + Achievement(1007, "Old condition", "reviews_1", 100)); + await db.SaveChangesAsync(); + + await service.CheckAndAwardAchievementsAsync(1); + await service.CheckAndAwardAchievementsAsync(1); + + var user = await db.Users.FindAsync(1); + Assert.NotNull(user); + Assert.Equal(720, user!.Coins); + Assert.Equal(310, user.Xp); + Assert.Equal(6, await db.UserAchievements.CountAsync(ua => ua.UserId == 1)); + Assert.False(await db.UserAchievements.AnyAsync(ua => ua.AchievementId == 1007)); + Assert.Equal(6, await db.CoinTransactions.CountAsync(ct => + ct.UserId == 1 && ct.Type == CoinTransactionType.AchievementReward)); + } + + [Fact] + public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears() + { + await using var db = CreateDbContext(); + var service = CreateService(db); + + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.AddRange( + Lecture(1, new DateTime(2025, 12, 29, 10, 0, 0, DateTimeKind.Utc)), + Lecture(2, new DateTime(2026, 1, 5, 10, 0, 0, DateTimeKind.Utc)), + Lecture(3, new DateTime(2026, 1, 12, 10, 0, 0, DateTimeKind.Utc))); + db.LectureEnrollments.AddRange( + new LectureEnrollment { UserId = 1, LectureId = 1, Attended = true }, + new LectureEnrollment { UserId = 1, LectureId = 2, Attended = true }, + new LectureEnrollment { UserId = 1, LectureId = 3, Attended = true }); + db.Achievements.Add(Achievement(1001, "Streak", "attendance_streak_weeks:3", 10)); + await db.SaveChangesAsync(); + + await service.CheckAndAwardAchievementsAsync(1); + + Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001)); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private static GamificationService CreateService(AppDbContext db) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Gamification:XpThresholds:0"] = "0", + ["Gamification:XpThresholds:1"] = "100", + ["Gamification:XpThresholds:2"] = "300" + }) + .Build(); + + return new GamificationService(db, configuration, NullLogger.Instance); + } + + private static Achievement Achievement(int id, string name, string condition, int coinReward) => new() + { + Id = id, + Name = name, + Condition = condition, + CoinReward = coinReward + }; + + private static Lecture Lecture(int id, DateTime startsAt) => new() + { + Id = id, + CourseId = 1, + Title = $"Lecture {id}", + StartsAt = startsAt, + EndsAt = startsAt.AddHours(2) + }; +} diff --git a/backend/UniVerse.Api/BackgroundServices/AchievementCatalogHostedService.cs b/backend/UniVerse.Api/BackgroundServices/AchievementCatalogHostedService.cs new file mode 100644 index 0000000..15eb6f8 --- /dev/null +++ b/backend/UniVerse.Api/BackgroundServices/AchievementCatalogHostedService.cs @@ -0,0 +1,17 @@ +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Api.BackgroundServices; + +public class AchievementCatalogHostedService : IHostedService +{ + private readonly IServiceProvider _services; + + public AchievementCatalogHostedService(IServiceProvider services) => _services = services; + + public async Task StartAsync(CancellationToken cancellationToken) + { + await AchievementCatalogSeeder.SeedAsync(_services, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 099f9f0..f082b7c 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -118,6 +118,7 @@ builder.Services.AddHttpClient(client => // --- Background Services --- builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // --- Controllers --- builder.Services.AddControllers() diff --git a/backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs b/backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs new file mode 100644 index 0000000..a9899ff --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/AchievementCatalogSeeder.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data; + +public static class AchievementCatalogSeeder +{ + private static readonly IReadOnlyList Catalog = + [ + new(1001, "Добро пожаловать в UniVerse", "Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.", "sparkles", 10, "first_activity:1"), + new(1002, "Первый шаг", "Посетить первую открытую лекцию.", "book-2", 10, "lectures_attended:1"), + new(1003, "Вошел во вкус", "Посетить 3 открытые лекции.", "books", 20, "lectures_attended:3"), + new(1004, "Постоянный слушатель", "Посетить 5 открытых лекций.", "calendar-event", 35, "lectures_attended:5"), + new(1005, "Академический марафон", "Посетить 10 открытых лекций.", "stopwatch", 60, "lectures_attended:10"), + new(1006, "Грандмастер лекций", "Посетить 25 открытых лекций.", "trophy", 120, "lectures_attended:25"), + new(1007, "Первый отзыв", "Оставить первый отзыв о посещенной лекции.", "message-circle", 10, "reviews_written:1"), + new(1008, "Голос аудитории", "Оставить 3 отзыва о лекциях.", "thumb-up", 25, "reviews_written:3"), + new(1009, "Рецензент", "Оставить 10 отзывов о лекциях.", "clipboard-list", 70, "reviews_written:10"), + new(1010, "Голос перемен", "Оставить 25 отзывов о лекциях.", "chart-line", 150, "reviews_written:25"), + new(1011, "Смелый выбор", "Записаться на первую открытую лекцию.", "calendar", 5, "lectures_registered:1"), + new(1012, "План на неделю", "Иметь 3 активные записи на будущие лекции.", "calendar-event", 15, "active_registrations:3"), + new(1013, "Полный календарь", "Иметь 5 активных записей на будущие лекции.", "alarm", 30, "active_registrations:5"), + new(1014, "Серия интереса", "Посещать открытые лекции 3 недели подряд.", "star", 50, "attendance_streak_weeks:3"), + new(1015, "Учебный месяц", "Посещать открытые лекции 4 недели подряд.", "sparkles", 80, "attendance_streak_weeks:4"), + new(1016, "Без пропусков", "Посетить 5 лекций, на которые была оформлена запись.", "circle-check", 40, "attended_registered:5"), + new(1017, "Надежный участник", "Посетить 10 лекций, на которые была оформлена запись.", "shield", 75, "attended_registered:10"), + new(1018, "Капитал знаний", "Получить 500 монет за активность на платформе.", "coin", 80, "coins_earned:500"), + new(1019, "Новый уровень", "Достигнуть 2 уровня.", "star", 25, "level_reached:2"), + new(1020, "Уверенный рост", "Достигнуть 5 уровня.", "chart-bar", 100, "level_reached:5"), + new(1021, "Профиль заполнен", "Заполнить имя и аватар в профиле.", "user", 10, "profile_completed:1") + ]; + + private static readonly IReadOnlyDictionary LegacyConditions = new Dictionary + { + ["reviews_1"] = "reviews_written:1", + ["reviews_5"] = "reviews_written:5", + ["reviews_10"] = "reviews_written:10", + ["attended_5"] = "lectures_attended:5", + ["attended_10"] = "lectures_attended:10" + }; + + public static async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken = default) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var legacyConditionKeys = LegacyConditions.Keys.ToArray(); + + var legacyAchievements = await db.Achievements + .Where(a => a.Condition != null && legacyConditionKeys.Contains(a.Condition)) + .ToListAsync(cancellationToken); + + foreach (var achievement in legacyAchievements) + achievement.Condition = LegacyConditions[achievement.Condition!]; + + foreach (var seed in Catalog) + { + var achievement = await db.Achievements.FindAsync([seed.Id], cancellationToken); + if (achievement == null) + { + db.Achievements.Add(new Achievement + { + Id = seed.Id, + Name = seed.Name, + Description = seed.Description, + IconUrl = seed.IconUrl, + XpReward = 0, + CoinReward = seed.CoinReward, + Condition = seed.Condition + }); + continue; + } + + achievement.Name = seed.Name; + achievement.Description = seed.Description; + achievement.IconUrl = seed.IconUrl; + achievement.XpReward = 0; + achievement.CoinReward = seed.CoinReward; + achievement.Condition = seed.Condition; + } + + await db.SaveChangesAsync(cancellationToken); + } + + private sealed record AchievementSeed( + int Id, + string Name, + string Description, + string IconUrl, + int CoinReward, + string Condition); +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260512120000_SeedAchievements.cs b/backend/UniVerse.Infrastructure/Migrations/20260512120000_SeedAchievements.cs new file mode 100644 index 0000000..b81e94f --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260512120000_SeedAchievements.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260512120000_SeedAchievements")] + public partial class SeedAchievements : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + UPDATE achievements + SET condition = CASE condition + WHEN 'reviews_1' THEN 'reviews_written:1' + WHEN 'reviews_5' THEN 'reviews_written:5' + WHEN 'reviews_10' THEN 'reviews_written:10' + WHEN 'attended_5' THEN 'lectures_attended:5' + WHEN 'attended_10' THEN 'lectures_attended:10' + ELSE condition + END + WHERE condition IN ('reviews_1', 'reviews_5', 'reviews_10', 'attended_5', 'attended_10'); + """); + + migrationBuilder.Sql(""" + INSERT INTO achievements (id, name, description, icon_url, xp_reward, coin_reward, condition, created_at) + VALUES + (1001, 'Добро пожаловать в UniVerse', 'Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.', 'sparkles', 0, 10, 'first_activity:1', NOW()), + (1002, 'Первый шаг', 'Посетить первую открытую лекцию.', 'book-2', 0, 10, 'lectures_attended:1', NOW()), + (1003, 'Вошел во вкус', 'Посетить 3 открытые лекции.', 'books', 0, 20, 'lectures_attended:3', NOW()), + (1004, 'Постоянный слушатель', 'Посетить 5 открытых лекций.', 'calendar-event', 0, 35, 'lectures_attended:5', NOW()), + (1005, 'Академический марафон', 'Посетить 10 открытых лекций.', 'stopwatch', 0, 60, 'lectures_attended:10', NOW()), + (1006, 'Грандмастер лекций', 'Посетить 25 открытых лекций.', 'trophy', 0, 120, 'lectures_attended:25', NOW()), + (1007, 'Первый отзыв', 'Оставить первый отзыв о посещенной лекции.', 'message-circle', 0, 10, 'reviews_written:1', NOW()), + (1008, 'Голос аудитории', 'Оставить 3 отзыва о лекциях.', 'thumb-up', 0, 25, 'reviews_written:3', NOW()), + (1009, 'Рецензент', 'Оставить 10 отзывов о лекциях.', 'clipboard-list', 0, 70, 'reviews_written:10', NOW()), + (1010, 'Голос перемен', 'Оставить 25 отзывов о лекциях.', 'chart-line', 0, 150, 'reviews_written:25', NOW()), + (1011, 'Смелый выбор', 'Записаться на первую открытую лекцию.', 'calendar', 0, 5, 'lectures_registered:1', NOW()), + (1012, 'План на неделю', 'Иметь 3 активные записи на будущие лекции.', 'calendar-event', 0, 15, 'active_registrations:3', NOW()), + (1013, 'Полный календарь', 'Иметь 5 активных записей на будущие лекции.', 'alarm', 0, 30, 'active_registrations:5', NOW()), + (1014, 'Серия интереса', 'Посещать открытые лекции 3 недели подряд.', 'star', 0, 50, 'attendance_streak_weeks:3', NOW()), + (1015, 'Учебный месяц', 'Посещать открытые лекции 4 недели подряд.', 'sparkles', 0, 80, 'attendance_streak_weeks:4', NOW()), + (1016, 'Без пропусков', 'Посетить 5 лекций, на которые была оформлена запись.', 'circle-check', 0, 40, 'attended_registered:5', NOW()), + (1017, 'Надежный участник', 'Посетить 10 лекций, на которые была оформлена запись.', 'shield', 0, 75, 'attended_registered:10', NOW()), + (1018, 'Капитал знаний', 'Получить 500 монет за активность на платформе.', 'coin', 0, 80, 'coins_earned:500', NOW()), + (1019, 'Новый уровень', 'Достигнуть 2 уровня.', 'star', 0, 25, 'level_reached:2', NOW()), + (1020, 'Уверенный рост', 'Достигнуть 5 уровня.', 'chart-bar', 0, 100, 'level_reached:5', NOW()), + (1021, 'Профиль заполнен', 'Заполнить имя и аватар в профиле.', 'user', 0, 10, 'profile_completed:1', NOW()) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + icon_url = EXCLUDED.icon_url, + xp_reward = EXCLUDED.xp_reward, + coin_reward = EXCLUDED.coin_reward, + condition = EXCLUDED.condition; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM achievements WHERE id BETWEEN 1001 AND 1021;"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Services/AchievementService.cs b/backend/UniVerse.Infrastructure/Services/AchievementService.cs index 12505d0..0d90b58 100644 --- a/backend/UniVerse.Infrastructure/Services/AchievementService.cs +++ b/backend/UniVerse.Infrastructure/Services/AchievementService.cs @@ -14,7 +14,7 @@ public class AchievementService : IAchievementService public AchievementService(AppDbContext db) => _db = db; public async Task> GetAllAsync() => - await _db.Achievements.OrderBy(a => a.Name).Select(a => a.ToDto()).ToListAsync(); + await _db.Achievements.OrderBy(a => a.Id).Select(a => a.ToDto()).ToListAsync(); public async Task GetByIdAsync(int id) { diff --git a/backend/UniVerse.Infrastructure/Services/GamificationService.cs b/backend/UniVerse.Infrastructure/Services/GamificationService.cs index 077598c..b4bb705 100644 --- a/backend/UniVerse.Infrastructure/Services/GamificationService.cs +++ b/backend/UniVerse.Infrastructure/Services/GamificationService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using System.Globalization; using UniVerse.Application.DTOs.Achievements; using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Gamification; @@ -41,32 +42,109 @@ public class GamificationService : IGamificationService public async Task CheckAndAwardAchievementsAsync(int userId) { - var achievements = await _db.Achievements.ToListAsync(); + var user = await _db.Users + .Include(u => u.StudentProfile) + .FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) return; + + var achievements = await _db.Achievements.OrderBy(a => a.Id).ToListAsync(); var existing = await _db.UserAchievements.Where(ua => ua.UserId == userId) .Select(ua => ua.AchievementId).ToListAsync(); var reviews = await _db.Reviews.CountAsync(r => r.UserId == userId); + var registered = await _db.LectureEnrollments.CountAsync(e => e.UserId == userId); var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == userId && e.Attended); + var activeRegistrations = await _db.LectureEnrollments + .Include(e => e.Lecture) + .CountAsync(e => e.UserId == userId && e.Lecture.StartsAt > DateTime.UtcNow); + var earnedCoins = await _db.CoinTransactions + .Where(ct => ct.UserId == userId && ct.Amount > 0) + .SumAsync(ct => (int?)ct.Amount) ?? 0; + var attendanceStreakWeeks = await CalculateAttendanceStreakWeeksAsync(userId); + var profileCompleted = !string.IsNullOrWhiteSpace(user.DisplayName) + && !string.IsNullOrWhiteSpace(user.AvatarUrl); + var firstActivity = registered > 0 || reviews > 0 || attended > 0; foreach (var achievement in achievements.Where(a => !existing.Contains(a.Id))) { - var earned = achievement.Condition switch + if (!TryParseCondition(achievement.Condition, out var type, out var value)) continue; + + var earned = type switch { - "reviews_1" => reviews >= 1, - "reviews_5" => reviews >= 5, - "reviews_10" => reviews >= 10, - "attended_5" => attended >= 5, - "attended_10" => attended >= 10, + "lectures_attended" => attended >= value, + "reviews_written" => reviews >= value, + "lectures_registered" => registered >= value, + "active_registrations" => activeRegistrations >= value, + "attendance_streak_weeks" => attendanceStreakWeeks >= value, + "attended_registered" => attended >= value, + "coins_earned" => earnedCoins >= value, + "level_reached" => CalculateLevel(user.Xp) >= value, + "profile_completed" => profileCompleted && value <= 1, + "first_activity" => firstActivity && value <= 1, _ => false }; if (!earned) continue; + _db.UserAchievements.Add(new UserAchievement { UserId = userId, AchievementId = achievement.Id }); if (achievement.CoinReward > 0) + { await AwardCoinsAsync(userId, achievement.CoinReward, CoinTransactionType.AchievementReward, achievementId: achievement.Id, description: $"Achievement: {achievement.Name}"); + earnedCoins += achievement.CoinReward; + } } await _db.SaveChangesAsync(); } + private static bool TryParseCondition(string? condition, out string type, out int value) + { + type = string.Empty; + value = 0; + + var parts = condition?.Split(':', 2, StringSplitOptions.TrimEntries); + if (parts is not { Length: 2 } || string.IsNullOrWhiteSpace(parts[0])) return false; + if (!int.TryParse(parts[1], out value)) return false; + + type = parts[0]; + return value >= 0; + } + + private async Task CalculateAttendanceStreakWeeksAsync(int userId) + { + var lectureDates = await _db.LectureEnrollments + .Include(e => e.Lecture) + .Where(e => e.UserId == userId && e.Attended) + .Select(e => e.Lecture.StartsAt) + .ToListAsync(); + + var weekStarts = lectureDates + .Select(GetIsoWeekStart) + .Distinct() + .OrderBy(d => d) + .ToList(); + + var best = 0; + var current = 0; + DateOnly? previous = null; + + foreach (var weekStart in weekStarts) + { + current = previous.HasValue && weekStart.DayNumber - previous.Value.DayNumber == 7 + ? current + 1 + : 1; + best = Math.Max(best, current); + previous = weekStart; + } + + return best; + } + + private static DateOnly GetIsoWeekStart(DateTime date) + { + var isoYear = ISOWeek.GetYear(date); + var isoWeek = ISOWeek.GetWeekOfYear(date); + return DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday)); + } + public int CalculateLevel(int xp) { var thresholds = _config.GetSection("Gamification:XpThresholds").Get() diff --git a/backend/UniVerse.Infrastructure/Services/LectureService.cs b/backend/UniVerse.Infrastructure/Services/LectureService.cs index b28ffdc..5d7a279 100644 --- a/backend/UniVerse.Infrastructure/Services/LectureService.cs +++ b/backend/UniVerse.Infrastructure/Services/LectureService.cs @@ -12,7 +12,13 @@ namespace UniVerse.Infrastructure.Services; public class LectureService : ILectureService { private readonly AppDbContext _db; - public LectureService(AppDbContext db) => _db = db; + private readonly IGamificationService _gamification; + + public LectureService(AppDbContext db, IGamificationService gamification) + { + _db = db; + _gamification = gamification; + } private IQueryable BaseQuery() => _db.Lectures .Include(l => l.Course).Include(l => l.Teacher) @@ -96,6 +102,7 @@ public class LectureService : ILectureService throw new ConflictException("Already enrolled."); _db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId }); await _db.SaveChangesAsync(); + await _gamification.CheckAndAwardAchievementsAsync(userId); } public async Task UnenrollAsync(int lectureId, int userId) @@ -114,6 +121,8 @@ public class LectureService : ILectureService ?? throw new NotFoundException("Enrollment not found."); enrollment.Attended = attended; await _db.SaveChangesAsync(); + if (attended) + await _gamification.CheckAndAwardAchievementsAsync(userId); } public async Task> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination) diff --git a/backend/UniVerse.Infrastructure/Services/ReviewService.cs b/backend/UniVerse.Infrastructure/Services/ReviewService.cs index b70aaba..b27b776 100644 --- a/backend/UniVerse.Infrastructure/Services/ReviewService.cs +++ b/backend/UniVerse.Infrastructure/Services/ReviewService.cs @@ -13,7 +13,13 @@ namespace UniVerse.Infrastructure.Services; public class ReviewService : IReviewService { private readonly AppDbContext _db; - public ReviewService(AppDbContext db) => _db = db; + private readonly IGamificationService _gamification; + + public ReviewService(AppDbContext db, IGamificationService gamification) + { + _db = db; + _gamification = gamification; + } private IQueryable BaseQuery() => _db.Reviews .Include(r => r.Lecture).Include(r => r.User); @@ -31,6 +37,7 @@ public class ReviewService : IReviewService }; _db.Reviews.Add(review); await _db.SaveChangesAsync(); + await _gamification.CheckAndAwardAchievementsAsync(userId); var full = await BaseQuery().FirstAsync(r => r.Id == review.Id); return full.ToDto(); } diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index e80847a..9d67158 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -41,6 +41,7 @@ public class UserService : IUserService user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); + await _gamification.CheckAndAwardAchievementsAsync(id); return user.ToDto(_gamification.CalculateLevel(user.Xp)); } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index cf69825..e15591a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -78,6 +78,13 @@ export const usersApi = { apiRequest(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }), } +export const achievementsApi = { + async list() { + const payload = await apiRequest | AchievementDto[]>('/achievements') + return extractItems(payload) + }, +} + export const reviewsApi = { create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) => apiRequest('/reviews', { diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 06d9108..9655813 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { usersApi } from '@/api' +import { achievementsApi, usersApi } from '@/api' import { mapApiAchievement, mapApiCoinTransaction } from '@/api/mappers' import type { Achievement, CoinTransaction, Notification } from '@/types' import { useAuthStore } from './auth' @@ -25,6 +25,7 @@ export const useUserStore = defineStore('user', () => { usersApi.achievements(id), usersApi.transactions(id), ]) + const achievementCatalog = await achievementsApi.list() if (auth.user) { auth.setUser({ @@ -37,7 +38,22 @@ export const useUserStore = defineStore('user', () => { achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)), }) } - achievements.value = achievementPayload.map(mapApiAchievement) + const unlocked = new Map(achievementPayload.map(item => { + const achievement = mapApiAchievement(item) + return [achievement.id, achievement] + })) + const catalogIds = new Set(achievementCatalog.map(item => String(item.id))) + const lockedAndUnlocked = achievementCatalog.map(item => { + const achievement = mapApiAchievement(item) + return unlocked.get(achievement.id) ?? achievement + }) + const unlockedOutsideCatalog = achievementPayload + .map(mapApiAchievement) + .filter(item => !catalogIds.has(item.id)) + + achievements.value = [...lockedAndUnlocked, ...unlockedOutsideCatalog].sort( + (a, b) => Number(a.id) - Number(b.id), + ) coinHistory.value = transactions.map(mapApiCoinTransaction) } catch (err) { error.value = err instanceof Error ? err.message : 'Не удалось загрузить данные профиля.'