using System.Buffers.Binary; using System.Security.Cryptography; using System.Text; using Ical.Net; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Users; 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; public class UserService : IUserService { private const byte CalendarTokenVersion = 1; private const int CalendarTokenPayloadLength = 5; private const int CalendarTokenSignatureLength = 32; private const string CalendarTokenKeyContext = "universe-calendar-subscription-v1"; private readonly AppDbContext _db; private readonly IGamificationService _gamification; private readonly IConfiguration _config; public UserService(AppDbContext db, IGamificationService gamification, IConfiguration config) { _db = db; _gamification = gamification; _config = config; } public async Task GetByIdAsync(int id) { var user = await _db.Users .Include(u => u.Roles) .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)); } public async Task UpdateProfileAsync(int id, UpdateUserRequest request) { var user = await _db.Users .Include(u => u.Roles) .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); if (request.DisplayName != null) user.DisplayName = request.DisplayName; if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl; user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); await _gamification.CheckAndAwardAchievementsAsync(id); return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)); } public async Task GetStatsAsync(int id) { var user = await _db.Users.FindAsync(id) ?? throw new NotFoundException("User", id); var totalLectures = await _db.LectureEnrollments.CountAsync(e => e.UserId == id); 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, activeEnrollments, slotLimit, slotRules ); } public async Task GetAdminDashboardStatsAsync() { var usersCount = await _db.Users .CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher)); var lecturesCount = await _db.Lectures.CountAsync(); var enrollmentsCount = await _db.LectureEnrollments.CountAsync(); var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending); return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount); } public async Task> GetEnrollmentsAsync(int id, PaginationRequest pagination) { if (!await _db.Users.AnyAsync(u => u.Id == id)) throw new NotFoundException("User", id); var query = _db.LectureEnrollments .Where(e => e.UserId == id) .Include(e => e.Lecture) .ThenInclude(l => l.Course) .Include(e => e.Lecture) .ThenInclude(l => l.Teacher) .Include(e => e.Lecture) .ThenInclude(l => l.Location) .Include(e => e.Lecture) .ThenInclude(l => l.Enrollments); var total = await query.CountAsync(); var enrollments = await query .OrderBy(e => e.Lecture.StartsAt) .Skip((pagination.Page - 1) * pagination.PageSize) .Take(pagination.PageSize) .ToListAsync(); return PagedResult.Create( enrollments.Select(e => e.Lecture.ToDto(isEnrolled: true)).ToList(), total, pagination.Page, pagination.PageSize); } public async Task GetMyEnrollmentsIcsAsync(int userId) { if (!await _db.Users.AnyAsync(u => u.Id == userId)) throw new NotFoundException("User", userId); var lectures = await _db.LectureEnrollments .Where(e => e.UserId == userId) .Include(e => e.Lecture) .ThenInclude(l => l.Teacher) .Include(e => e.Lecture) .ThenInclude(l => l.Location) .OrderBy(e => e.Lecture.StartsAt) .Select(e => e.Lecture) .ToListAsync(); return BuildIcs(lectures, userId); } public async Task GetEnrollmentIcsAsync(int userId, int lectureId) { if (!await _db.Users.AnyAsync(u => u.Id == userId)) throw new NotFoundException("User", userId); var lecture = await _db.Lectures .Include(l => l.Teacher) .Include(l => l.Location) .FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId); return BuildIcs([lecture], userId); } public async Task GetCalendarSubscriptionTokenAsync(int userId) { if (!await _db.Users.AnyAsync(u => u.Id == userId)) throw new NotFoundException("User", userId); Span payload = stackalloc byte[CalendarTokenPayloadLength]; payload[0] = CalendarTokenVersion; BinaryPrimitives.WriteInt32BigEndian(payload[1..], userId); var signature = SignCalendarTokenPayload(payload); var tokenBytes = new byte[CalendarTokenPayloadLength + CalendarTokenSignatureLength]; payload.CopyTo(tokenBytes); signature.CopyTo(tokenBytes.AsSpan(CalendarTokenPayloadLength)); return ToBase64Url(tokenBytes); } public async Task GetEnrollmentsIcsBySubscriptionTokenAsync(string token) { var userId = ValidateCalendarSubscriptionToken(token); return await GetMyEnrollmentsIcsAsync(userId); } private int ValidateCalendarSubscriptionToken(string token) { var tokenBytes = FromBase64Url(token); if (tokenBytes.Length != CalendarTokenPayloadLength + CalendarTokenSignatureLength) throw new ForbiddenException("Invalid calendar subscription token."); var payload = tokenBytes.AsSpan(0, CalendarTokenPayloadLength); var signature = tokenBytes.AsSpan(CalendarTokenPayloadLength, CalendarTokenSignatureLength); if (payload[0] != CalendarTokenVersion) throw new ForbiddenException("Invalid calendar subscription token."); var expectedSignature = SignCalendarTokenPayload(payload); if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature)) throw new ForbiddenException("Invalid calendar subscription token."); var userId = BinaryPrimitives.ReadInt32BigEndian(payload[1..]); if (userId <= 0) throw new ForbiddenException("Invalid calendar subscription token."); return userId; } private byte[] SignCalendarTokenPayload(ReadOnlySpan payload) { var calendarKey = DeriveCalendarTokenKey(); return HMACSHA256.HashData(calendarKey, payload); } private byte[] DeriveCalendarTokenKey() { var jwtSecret = _config["Jwt:Secret"]; if (string.IsNullOrWhiteSpace(jwtSecret)) throw new InvalidOperationException("Jwt:Secret is not configured."); return HMACSHA256.HashData( Encoding.UTF8.GetBytes(jwtSecret), Encoding.UTF8.GetBytes(CalendarTokenKeyContext)); } private static string ToBase64Url(ReadOnlySpan bytes) => Convert.ToBase64String(bytes) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); private static byte[] FromBase64Url(string value) { try { var padded = value.Replace('-', '+').Replace('_', '/'); padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); return Convert.FromBase64String(padded); } catch (FormatException) { throw new ForbiddenException("Invalid calendar subscription token."); } } private static string BuildIcs(List lectures, int userId) { var calendar = new Calendar { Method = "PUBLISH", ProductId = "-//UniVerse//Lectures Calendar//EN" }; foreach (var lecture in lectures) { var location = lecture.Location is null ? string.Empty : $"{lecture.Location.Building}{(string.IsNullOrWhiteSpace(lecture.Location.Room) ? string.Empty : $", ауд. {lecture.Location.Room}")}"; var teacherName = lecture.Teacher?.DisplayName ?? lecture.Teacher?.Email ?? "не указан"; calendar.Events.Add(new CalendarEvent { Uid = $"lecture-{lecture.Id}-user-{userId}@universe.local", Summary = lecture.Title, Description = $"{lecture.Description}\nПреподаватель: {teacherName}", Location = location, DtStart = new CalDateTime(DateTime.SpecifyKind(lecture.StartsAt, DateTimeKind.Utc)), DtEnd = new CalDateTime(DateTime.SpecifyKind(lecture.EndsAt, DateTimeKind.Utc)), DtStamp = new CalDateTime(DateTime.UtcNow) }); } return new CalendarSerializer().SerializeToString(calendar) ?? string.Empty; } public async Task> GetAllAsync(UserFilterRequest filter) { var query = _db.Users.AsQueryable(); if (!string.IsNullOrEmpty(filter.Search)) { var search = filter.Search.ToLower(); query = query.Where(u => u.Email.ToLower().Contains(search) || (u.DisplayName != null && u.DisplayName.ToLower().Contains(search))); } query = query.Include(u => u.Roles); if (filter.Role.HasValue) { var role = filter.Role.Value; query = query.Where(u => u.Roles.Count == 1 && u.Roles.Any(ur => ur.Role == role)); } if (filter.IsActive.HasValue) query = query.Where(u => u.IsActive == filter.IsActive.Value); var total = await query.CountAsync(); var users = await query .OrderByDescending(u => u.CreatedAt) .Skip((filter.Page - 1) * filter.PageSize) .Take(filter.PageSize) .ToListAsync(); 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); } public async Task SetRolesAsync(int id, IReadOnlyCollection roles) { var normalizedRoles = roles.Distinct().ToList(); if (normalizedRoles.Count == 0) throw new ForbiddenException("At least one role is required."); var user = await _db.Users .Include(u => u.Roles) .FirstOrDefaultAsync(u => u.Id == id) ?? throw new NotFoundException("User", id); var existing = user.Roles.Select(r => r.Role).ToHashSet(); var toRemove = user.Roles.Where(r => !normalizedRoles.Contains(r.Role)).ToList(); foreach (var item in toRemove) user.Roles.Remove(item); var toAdd = normalizedRoles.Where(r => !existing.Contains(r)).ToList(); foreach (var role in toAdd) user.Roles.Add(new Domain.Entities.UserRoleAssignment { UserId = user.Id, Role = role }); await EnsureProfilesForRolesAsync(user.Id, normalizedRoles); user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } public async Task SetActiveAsync(int id, bool isActive) { var user = await _db.Users.FindAsync(id) ?? throw new NotFoundException("User", id); user.IsActive = isActive; user.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } private async Task EnsureProfilesForRolesAsync(int userId, IReadOnlyCollection roles) { if (roles.Contains(UserRole.Student)) { var hasStudentProfile = await _db.StudentProfiles.AnyAsync(p => p.UserId == userId); if (!hasStudentProfile) _db.StudentProfiles.Add(new Domain.Entities.StudentProfile { UserId = userId }); } if (roles.Contains(UserRole.Teacher)) { var hasTeacherProfile = await _db.TeacherProfiles.AnyAsync(p => p.UserId == userId); if (!hasTeacherProfile) _db.TeacherProfiles.Add(new Domain.Entities.TeacherProfile { UserId = userId }); } } }