136bcce7db
Backend CI / build-and-test (push) Successful in 57s
Frontend CI / build-and-check (push) Failing after 26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m33s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 33s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
372 lines
14 KiB
C#
372 lines
14 KiB
C#
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<UserDto> 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<UserDto> 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<UserStatsDto> 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<AdminDashboardStatsDto> 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<PagedResult<LectureDto>> 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<LectureDto>.Create(
|
||
enrollments.Select(e => e.Lecture.ToDto(isEnrolled: true)).ToList(),
|
||
total,
|
||
pagination.Page,
|
||
pagination.PageSize);
|
||
}
|
||
|
||
|
||
public async Task<string> 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<string> 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<string> GetCalendarSubscriptionTokenAsync(int userId)
|
||
{
|
||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
||
throw new NotFoundException("User", userId);
|
||
|
||
Span<byte> 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<string> 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<byte> 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<byte> 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<Domain.Entities.Lecture> 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<PagedResult<UserDto>> 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<UserDto>(users.Count);
|
||
foreach (var user in users)
|
||
items.Add(user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)));
|
||
|
||
return PagedResult<UserDto>.Create(items, total, filter.Page, filter.PageSize);
|
||
}
|
||
|
||
public async Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> 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<UserRole> 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 });
|
||
}
|
||
}
|
||
}
|