diff --git a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs index 9a93e41..528e09b 100644 --- a/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs +++ b/backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs @@ -161,7 +161,19 @@ 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, 0, 100)); + stub.GetStatsAsync(Arg.Any()).Returns(new UserStatsDto( + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 100, + 0, + 3, + [new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)])); stub.GetEnrollmentsAsync(Arg.Any(), Arg.Any()).Returns(pagedLectures); stub.GetAllAsync(Arg.Any()).Returns(pagedUsers); stub.SetRolesAsync(Arg.Any(), Arg.Any>()).Returns(Task.CompletedTask); diff --git a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs index 09ca7b2..3d170ff 100644 --- a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs @@ -4,6 +4,7 @@ using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; +using UniVerse.Domain.Exceptions; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Services; using Xunit; @@ -92,6 +93,79 @@ public class LectureServiceTests Arg.Any()); } + [Theory] + [InlineData(1, 3)] + [InlineData(2, 3)] + [InlineData(3, 5)] + [InlineData(4, 7)] + [InlineData(5, 7)] + public async Task EnrollAsync_ThrowsWhenActiveEnrollmentLimitReached(int level, int activeEnrollments) + { + await using var db = CreateDbContext(); + var gamification = Substitute.For(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(level); + var service = new LectureService(db, gamification, Substitute.For()); + var startsAt = DateTime.UtcNow.AddDays(1); + + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(100, startsAt.AddDays(100))); + for (var i = 1; i <= activeEnrollments; i++) + { + db.Lectures.Add(Lecture(i, startsAt.AddDays(i))); + db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 }); + } + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => service.EnrollAsync(100, 1)); + } + + [Fact] + public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit() + { + await using var db = CreateDbContext(); + var gamification = Substitute.For(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(1); + var service = new LectureService(db, gamification, Substitute.For()); + var now = DateTime.UtcNow; + + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(100, now.AddDays(1))); + for (var i = 1; i <= 3; i++) + { + db.Lectures.Add(Lecture(i, now.AddDays(-i))); + db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 }); + } + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => service.EnrollAsync(100, 1)); + } + + [Fact] + public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit() + { + await using var db = CreateDbContext(); + var gamification = Substitute.For(); + gamification.CalculateLevelAsync(Arg.Any()).Returns(1); + var service = new LectureService(db, gamification, Substitute.For()); + var now = DateTime.UtcNow; + + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(100, now.AddDays(1))); + for (var i = 1; i <= 3; i++) + { + db.Lectures.Add(Lecture(i, now.AddDays(-i))); + db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1, Attended = true }); + } + await db.SaveChangesAsync(); + + await service.EnrollAsync(100, 1); + + Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1)); + } + [Fact] public async Task UnenrollAsync_CancelsLectureReminders() { diff --git a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs index aa82b6a..7e13394 100644 --- a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs @@ -44,6 +44,33 @@ public class UserServiceTests Assert.Null(stats.NextLevelXp); } + [Fact] + public async Task GetStatsAsync_ReturnsEnrollmentSlotStateAndRules() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + var now = DateTime.UtcNow; + db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.AddRange( + Lecture(1, now.AddDays(1)), + Lecture(2, now.AddDays(2)), + Lecture(3, now.AddDays(-1))); + db.LectureEnrollments.AddRange( + new LectureEnrollment { LectureId = 1, UserId = 1 }, + new LectureEnrollment { LectureId = 2, UserId = 1 }, + new LectureEnrollment { LectureId = 3, UserId = 1 }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var stats = await service.GetStatsAsync(1); + + Assert.Equal(3, stats.ActiveEnrollments); + Assert.Equal(5, stats.EnrollmentSlotLimit); + Assert.Equal(new[] { 1, 3, 4 }, stats.EnrollmentSlotRules.Select(rule => rule.Level)); + Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots)); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -77,4 +104,15 @@ public class UserServiceTests new LevelThreshold { Level = 3, RequiredXp = 300 }); db.SaveChanges(); } + + private static Lecture Lecture(int id, DateTime startsAt) => new() + { + Id = id, + CourseId = 1, + Title = $"Lecture {id}", + StartsAt = startsAt, + EndsAt = startsAt.AddHours(2), + IsOpen = true, + MaxEnrollments = 30 + }; } diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index d4e6fc2..a8071e5 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -5135,6 +5135,20 @@ }, "additionalProperties": false }, + "EnrollmentSlotRuleDto": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "format": "int32" + }, + "slots": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "LectureDetailDto": { "type": "object", "properties": { @@ -6104,6 +6118,21 @@ "type": "integer", "format": "int32", "nullable": true + }, + "activeEnrollments": { + "type": "integer", + "format": "int32" + }, + "enrollmentSlotLimit": { + "type": "integer", + "format": "int32" + }, + "enrollmentSlotRules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrollmentSlotRuleDto" + }, + "nullable": true } }, "additionalProperties": false diff --git a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs index 63dedcb..328a3b2 100644 --- a/backend/UniVerse.Application/DTOs/Users/UserDtos.cs +++ b/backend/UniVerse.Application/DTOs/Users/UserDtos.cs @@ -35,9 +35,14 @@ public record UserStatsDto( int Level, int AchievementsCount, int CurrentLevelXp, - int? NextLevelXp + int? NextLevelXp, + int ActiveEnrollments, + int EnrollmentSlotLimit, + IReadOnlyList EnrollmentSlotRules ); +public record EnrollmentSlotRuleDto(int Level, int Slots); + public record UpdateUserRequest( string? DisplayName, string? AvatarUrl diff --git a/backend/UniVerse.Domain/Services/EnrollmentSlotPolicy.cs b/backend/UniVerse.Domain/Services/EnrollmentSlotPolicy.cs new file mode 100644 index 0000000..d99fcc3 --- /dev/null +++ b/backend/UniVerse.Domain/Services/EnrollmentSlotPolicy.cs @@ -0,0 +1,21 @@ +namespace UniVerse.Domain.Services; + +public static class EnrollmentSlotPolicy +{ + private static readonly IReadOnlyList SlotRules = + [ + new(1, 3), + new(3, 5), + new(4, 7) + ]; + + public static IReadOnlyList Rules => SlotRules; + + public static int GetLimitForLevel(int level) => + SlotRules + .Where(rule => rule.Level <= level) + .OrderBy(rule => rule.Level) + .LastOrDefault()?.Slots ?? SlotRules[0].Slots; +} + +public sealed record EnrollmentSlotRule(int Level, int Slots); diff --git a/backend/UniVerse.Infrastructure/Services/LectureService.cs b/backend/UniVerse.Infrastructure/Services/LectureService.cs index c3935a3..dc80784 100644 --- a/backend/UniVerse.Infrastructure/Services/LectureService.cs +++ b/backend/UniVerse.Infrastructure/Services/LectureService.cs @@ -6,6 +6,7 @@ using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; using UniVerse.Domain.Entities; using UniVerse.Domain.Exceptions; +using UniVerse.Domain.Services; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; @@ -124,6 +125,15 @@ public class LectureService : ILectureService throw new ConflictException("Lecture is full."); if (lecture.Enrollments.Any(e => e.UserId == userId)) throw new ConflictException("Already enrolled."); + + var level = await _gamification.CalculateLevelAsync(user.Xp); + var enrollmentLimit = EnrollmentSlotPolicy.GetLimitForLevel(level); + var activeEnrollments = await _db.LectureEnrollments + .CountAsync(e => e.UserId == userId && !e.Attended); + + if (activeEnrollments >= enrollmentLimit) + throw new ConflictException("Enrollment limit reached for your level."); + _db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId }); await _db.SaveChangesAsync(); await ScheduleLectureRemindersAsync(lecture, user); diff --git a/backend/UniVerse.Infrastructure/Services/UserService.cs b/backend/UniVerse.Infrastructure/Services/UserService.cs index 1397569..30cdd95 100644 --- a/backend/UniVerse.Infrastructure/Services/UserService.cs +++ b/backend/UniVerse.Infrastructure/Services/UserService.cs @@ -6,6 +6,7 @@ using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; using UniVerse.Domain.Enums; using UniVerse.Domain.Exceptions; +using UniVerse.Domain.Services; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; @@ -55,14 +56,21 @@ public class UserService : IUserService var attended = await _db.LectureEnrollments.CountAsync(e => e.UserId == id && e.Attended); var reviews = await _db.Reviews.CountAsync(r => r.UserId == id); var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id); + var activeEnrollments = await _db.LectureEnrollments + .CountAsync(e => e.UserId == id && !e.Attended); var level = await _gamification.CalculateLevelAsync(user.Xp); var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp); + var slotLimit = EnrollmentSlotPolicy.GetLimitForLevel(level); + var slotRules = EnrollmentSlotPolicy.Rules + .Select(rule => new EnrollmentSlotRuleDto(rule.Level, rule.Slots)) + .ToList(); return new UserStatsDto( totalLectures, attended, reviews, user.Xp, user.Coins, level, achievements, - levelProgress.CurrentLevelXp, levelProgress.NextLevelXp + levelProgress.CurrentLevelXp, levelProgress.NextLevelXp, + activeEnrollments, slotLimit, slotRules ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1e06d95..c6d16ae 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -68,6 +68,8 @@ export async function apiRequest( const message = typeof body === 'object' && body && 'message' in body ? String((body as { message: unknown }).message) + : typeof body === 'object' && body && 'detail' in body + ? String((body as { detail: unknown }).detail) : `API request failed with status ${response.status}` throw new ApiError(message, response.status, body) } diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 52b9d7f..c5b6c2d 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -50,6 +50,9 @@ export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: 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)) : [], + activeEnrollments: stats?.activeEnrollments, + enrollmentSlotLimit: stats?.enrollmentSlotLimit, + enrollmentSlotRules: stats?.enrollmentSlotRules, } } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b907a8b..66e7664 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -70,6 +70,14 @@ export interface UserStatsDto { achievementsCount: number currentLevelXp: number nextLevelXp?: number | null + activeEnrollments: number + enrollmentSlotLimit: number + enrollmentSlotRules: EnrollmentSlotRuleDto[] +} + +export interface EnrollmentSlotRuleDto { + level: number + slots: number } export interface LectureDto { diff --git a/frontend/src/components/layout/AppTopbar.vue b/frontend/src/components/layout/AppTopbar.vue index 0a1a989..dc4a813 100644 --- a/frontend/src/components/layout/AppTopbar.vue +++ b/frontend/src/components/layout/AppTopbar.vue @@ -17,6 +17,11 @@ const isCoinDialogOpen = ref(false) const profileMenuRef = ref(null) const unreadCount = computed(() => userStore.unreadCount()) +const enrollmentSlotText = computed(() => { + if (auth.user?.activeRole !== 'student') return '' + if (typeof auth.user.enrollmentSlotLimit !== 'number') return '' + return `${auth.user.activeEnrollments ?? 0}/${auth.user.enrollmentSlotLimit}` +}) const roleLabels: Record = { student: 'Студент', teacher: 'Преподаватель', @@ -85,6 +90,7 @@ function handleDocumentPointerDown(event: PointerEvent) { onMounted(() => { document.addEventListener('pointerdown', handleDocumentPointerDown) + if (auth.user?.roles.includes('student')) void userStore.fetchStats().catch(() => undefined) }) onBeforeUnmount(() => { @@ -115,6 +121,11 @@ onBeforeUnmount(() => { {{ auth.user.level }} +
+ + {{ enrollmentSlotText }} +
+ + + diff --git a/frontend/src/views/student/DashboardView.vue b/frontend/src/views/student/DashboardView.vue index 471ba24..fc755a9 100644 --- a/frontend/src/views/student/DashboardView.vue +++ b/frontend/src/views/student/DashboardView.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/views/student/LectureDetailView.vue b/frontend/src/views/student/LectureDetailView.vue index 0e934ce..3961932 100644 --- a/frontend/src/views/student/LectureDetailView.vue +++ b/frontend/src/views/student/LectureDetailView.vue @@ -1,19 +1,25 @@ diff --git a/frontend/src/views/student/ProfileView.vue b/frontend/src/views/student/ProfileView.vue index 1c4ceab..f6a132a 100644 --- a/frontend/src/views/student/ProfileView.vue +++ b/frontend/src/views/student/ProfileView.vue @@ -6,6 +6,7 @@ import GlassCard from '@/components/ui/GlassCard.vue' import ProgressBar from '@/components/ui/ProgressBar.vue' import AchievementBadge from '@/components/ui/AchievementBadge.vue' import EmptyState from '@/components/ui/EmptyState.vue' +import AppIcon from '@/components/ui/AppIcon.vue' const auth = useAuthStore() const userStore = useUserStore() @@ -41,6 +42,20 @@ const levelProgressLabel = computed(() => const levelProgressText = computed(() => hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP` ) +const activeEnrollments = computed(() => user.value.activeEnrollments ?? 0) +const enrollmentSlotLimit = computed(() => user.value.enrollmentSlotLimit ?? 0) +const enrollmentSlotsRemaining = computed(() => + Math.max(enrollmentSlotLimit.value - activeEnrollments.value, 0) +) +const enrollmentSlotRules = computed(() => user.value.enrollmentSlotRules ?? []) +const enrollmentSlotText = computed(() => + enrollmentSlotLimit.value ? `${activeEnrollments.value} / ${enrollmentSlotLimit.value}` : '...' +) +const enrollmentSlotHint = computed(() => { + if (!enrollmentSlotLimit.value) return 'Загружаем лимит активных записей.' + if (enrollmentSlotsRemaining.value === 0) return 'Все слоты заняты. Отмените запись, дождитесь отметки посещения или повышайте уровень.' + return `Можно записаться еще на ${formatLectureCount(enrollmentSlotsRemaining.value)}.` +}) const unlockedAchievements = computed(() => userStore.achievements.filter(a => a.unlocked)) const lockedAchievements = computed(() => userStore.achievements.filter(a => !a.unlocked)) const interestTags = ref([ @@ -54,6 +69,22 @@ const interestTags = ref([ const notificationSettings = ref({ email: true }) +function formatSlotCount(slots: number) { + const lastDigit = slots % 10 + const lastTwoDigits = slots % 100 + if (lastDigit === 1 && lastTwoDigits !== 11) return `${slots} слот` + if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${slots} слота` + return `${slots} слотов` +} + +function formatLectureCount(count: number) { + const lastDigit = count % 10 + const lastTwoDigits = count % 100 + if (lastDigit === 1 && lastTwoDigits !== 11) return `${count} лекцию` + if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 12 || lastTwoDigits > 14)) return `${count} лекции` + return `${count} лекций` +} + onMounted(() => { void userStore.fetchStudentData() }) @@ -99,6 +130,28 @@ onMounted(() => { + +
Слоты записи
+
+
+ + {{ enrollmentSlotText }} +
+

{{ enrollmentSlotHint }}

+
+
+
+ Ур. {{ rule.level }} + {{ formatSlotCount(rule.slots) }} +
+
+
+
Настройки уведомлений
@@ -167,8 +220,71 @@ onMounted(() => { .level { margin: 16px 0; } .level-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; } .tags-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.slot-overview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin: 14px 0; +} +.slot-meter { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 106px; + color: var(--color-primary-dark); + font-size: 26px; + font-weight: 800; +} +.slot-meter :deep(.app-icon) { + color: var(--color-primary); +} +.slot-hint { + margin: 0; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.4; + text-align: right; +} +.slot-rules { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.slot-rule { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px; + border: 1px solid var(--color-slate-500-a20); + border-radius: var(--radius-sm); + background: var(--color-white-a50); +} +.slot-rule span { + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 700; +} +.slot-rule strong { + color: var(--color-text); + font-size: 13px; +} +.slot-rule.active { + border-color: var(--color-primary-a30); + background: var(--color-primary-a08); +} .settings { display: flex; flex-direction: column; gap: 8px; } .setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; } .achievements-section { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; } .achievements-list { display: flex; flex-direction: column; gap: 12px; } + +@media (max-width: 520px) { + .slot-overview { + align-items: flex-start; + flex-direction: column; + } + .slot-hint { + text-align: left; + } +}