using Microsoft.EntityFrameworkCore; using UniVerse.Application.DTOs.Common; using UniVerse.Application.DTOs.Lectures; using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; using UniVerse.Application.Mappings; using UniVerse.Domain.Entities; using UniVerse.Domain.Exceptions; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; public class LectureService : ILectureService { private readonly AppDbContext _db; private readonly IGamificationService _gamification; private readonly INotificationScheduler _notificationScheduler; public LectureService( AppDbContext db, IGamificationService gamification, INotificationScheduler notificationScheduler) { _db = db; _gamification = gamification; _notificationScheduler = notificationScheduler; } private IQueryable BaseQuery() => _db.Lectures .Include(l => l.Course).Include(l => l.Teacher) .Include(l => l.Location).Include(l => l.Enrollments); public async Task> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null) { var query = BaseQuery(); if (filter.CourseId.HasValue) query = query.Where(l => l.CourseId == filter.CourseId); if (filter.TeacherId.HasValue) query = query.Where(l => l.TeacherId == filter.TeacherId); if (filter.Format.HasValue) query = query.Where(l => l.Format == filter.Format); if (filter.IsOpen.HasValue) query = query.Where(l => l.IsOpen == filter.IsOpen); if (filter.DateFrom.HasValue) query = query.Where(l => DateOnly.FromDateTime(l.StartsAt) >= filter.DateFrom); if (filter.DateTo.HasValue) query = query.Where(l => DateOnly.FromDateTime(l.StartsAt) <= filter.DateTo); if (!string.IsNullOrEmpty(filter.Search)) query = query.Where(l => l.Title.ToLower().Contains(filter.Search.ToLower())); if (filter.TagId.HasValue) query = query.Where(l => l.Course.CourseTags.Any(ct => ct.TagId == filter.TagId)); var total = await query.CountAsync(); var items = await query.OrderBy(l => l.StartsAt) .Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync(); return PagedResult.Create( items.Select(l => l.ToDto(currentUserId.HasValue && l.Enrollments.Any(e => e.UserId == currentUserId.Value))).ToList(), total, filter.Page, filter.PageSize); } public async Task GetByIdAsync(int id, int? currentUserId = null) { var lecture = await BaseQuery().FirstOrDefaultAsync(l => l.Id == id) ?? throw new NotFoundException("Lecture", id); var isEnrolled = currentUserId.HasValue && lecture.Enrollments.Any(e => e.UserId == currentUserId.Value); return lecture.ToDetailDto(isEnrolled); } public async Task CreateAsync(CreateLectureRequest req) { _ = await _db.Courses.FindAsync(req.CourseId) ?? throw new NotFoundException("Course", req.CourseId); var lecture = new Lecture { CourseId = req.CourseId, TeacherId = req.TeacherId, LocationId = req.LocationId, Title = req.Title, Description = req.Description, Format = req.Format, StartsAt = req.StartsAt, EndsAt = req.EndsAt, IsOpen = req.IsOpen, MaxEnrollments = req.MaxEnrollments, OnlineUrl = req.OnlineUrl }; _db.Lectures.Add(lecture); await _db.SaveChangesAsync(); var full = await BaseQuery().FirstAsync(l => l.Id == lecture.Id); return full.ToDto(); } public async Task UpdateAsync(int id, UpdateLectureRequest req) { var lecture = await _db.Lectures .Include(l => l.Location) .Include(l => l.Enrollments) .ThenInclude(e => e.User) .FirstOrDefaultAsync(l => l.Id == id) ?? throw new NotFoundException("Lecture", id); lecture.TeacherId = req.TeacherId; lecture.LocationId = req.LocationId; lecture.Title = req.Title; lecture.Description = req.Description; lecture.Format = req.Format; lecture.StartsAt = req.StartsAt; lecture.EndsAt = req.EndsAt; lecture.IsOpen = req.IsOpen; lecture.MaxEnrollments = req.MaxEnrollments; lecture.OnlineUrl = req.OnlineUrl; lecture.UpdatedAt = DateTime.UtcNow; lecture.Location = req.LocationId.HasValue ? await _db.Locations.FindAsync(req.LocationId.Value) : null; await _db.SaveChangesAsync(); await RescheduleLectureRemindersAsync(lecture); var full = await BaseQuery().FirstAsync(l => l.Id == id); return full.ToDto(); } public async Task DeleteAsync(int id) { var lecture = await _db.Lectures .Include(l => l.Enrollments) .FirstOrDefaultAsync(l => l.Id == id) ?? throw new NotFoundException("Lecture", id); await CancelLectureRemindersAsync(lecture); _db.Lectures.Remove(lecture); await _db.SaveChangesAsync(); } public async Task EnrollAsync(int lectureId, int userId) { var lecture = await _db.Lectures .Include(l => l.Location) .Include(l => l.Enrollments) .FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId); var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId); if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment."); if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments) throw new ConflictException("Lecture is full."); if (lecture.Enrollments.Any(e => e.UserId == userId)) throw new ConflictException("Already enrolled."); _db.LectureEnrollments.Add(new LectureEnrollment { LectureId = lectureId, UserId = userId }); await _db.SaveChangesAsync(); await ScheduleLectureRemindersAsync(lecture, user); await _gamification.CheckAndAwardAchievementsAsync(userId); } public async Task UnenrollAsync(int lectureId, int userId) { var enrollment = await _db.LectureEnrollments .FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId) ?? throw new NotFoundException("Enrollment not found."); await CancelLectureRemindersAsync(lectureId, userId); _db.LectureEnrollments.Remove(enrollment); await _db.SaveChangesAsync(); } public async Task MarkAttendanceAsync(int lectureId, int userId, bool attended) { var enrollment = await _db.LectureEnrollments .FirstOrDefaultAsync(e => e.LectureId == lectureId && e.UserId == userId) ?? 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) { var query = _db.LectureEnrollments.Include(e => e.User) .Where(e => e.LectureId == lectureId); var total = await query.CountAsync(); var items = await query.OrderBy(e => e.CreatedAt) .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); return PagedResult.Create(items.Select(e => e.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); } private async Task RescheduleLectureRemindersAsync(Lecture lecture) { foreach (var enrollment in lecture.Enrollments) { await ScheduleLectureRemindersAsync(lecture, enrollment.User); } } private async Task ScheduleLectureRemindersAsync(Lecture lecture, User user) { if (string.IsNullOrWhiteSpace(user.Email)) { await CancelLectureRemindersAsync(lecture.Id, user.Id); return; } foreach (var reminder in GetLectureReminders(lecture)) { var jobId = GetReminderJobId(lecture.Id, user.Id, reminder.Kind); await _notificationScheduler.CancelAsync(jobId); if (reminder.SendAt <= DateTimeOffset.UtcNow) continue; var message = new NotificationMessage( NotificationChannels.Email, user.Email, reminder.Subject, reminder.Body, user.DisplayName, new Dictionary { ["lectureId"] = lecture.Id.ToString(), ["reminderType"] = reminder.Kind }); await _notificationScheduler.ScheduleAsync(message, reminder.SendAt, jobId); } } private async Task CancelLectureRemindersAsync(Lecture lecture) { foreach (var enrollment in lecture.Enrollments) { await CancelLectureRemindersAsync(lecture.Id, enrollment.UserId); } } private async Task CancelLectureRemindersAsync(int lectureId, int userId) { foreach (var kind in new[] { "starts-in-3-hours", "starts-in-1-hour", "ended" }) { await _notificationScheduler.CancelAsync(GetReminderJobId(lectureId, userId, kind)); } } private static IEnumerable GetLectureReminders(Lecture lecture) { var startsAt = ToUtcOffset(lecture.StartsAt); var endsAt = ToUtcOffset(lecture.EndsAt); var place = !string.IsNullOrWhiteSpace(lecture.OnlineUrl) ? $"Ссылка: {lecture.OnlineUrl}" : lecture.Location is null ? null : $"Место проведения: {lecture.Location.Name}"; yield return new LectureReminder( "starts-in-3-hours", startsAt.AddHours(-3), $"Лекция \"{lecture.Title}\" начнется через 3 часа", BuildStartsSoonBody(lecture, startsAt, place, "3 часа")); yield return new LectureReminder( "starts-in-1-hour", startsAt.AddHours(-1), $"Лекция \"{lecture.Title}\" начнется через 1 час", BuildStartsSoonBody(lecture, startsAt, place, "1 час")); yield return new LectureReminder( "ended", endsAt, $"Оцените лекцию \"{lecture.Title}\"", BuildLectureEndedBody(lecture)); } private static string BuildStartsSoonBody(Lecture lecture, DateTimeOffset startsAt, string? place, string interval) { var lines = new List { $"Напоминаем: лекция \"{lecture.Title}\" начнется через {interval}.", $"Начало: {FormatDateTime(startsAt)}." }; if (!string.IsNullOrWhiteSpace(place)) lines.Add(place); return string.Join(Environment.NewLine, lines); } private static string BuildLectureEndedBody(Lecture lecture) => $"Лекция \"{lecture.Title}\" завершилась. Пожалуйста, оставьте оценку и отзыв в UniVerse: это поможет преподавателю и другим студентам."; private static string GetReminderJobId(int lectureId, int userId, string kind) => $"lecture-{lectureId}-user-{userId}-{kind}"; private static DateTimeOffset ToUtcOffset(DateTime value) { var utc = value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc); return new DateTimeOffset(utc); } private static string FormatDateTime(DateTimeOffset value) => value.ToString("dd.MM.yyyy HH:mm 'UTC'"); private sealed record LectureReminder(string Kind, DateTimeOffset SendAt, string Subject, string Body); }