From 926688cd2e06c5ccb44787562e114b4d46409e37 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Sat, 16 May 2026 11:19:31 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=20=D0=BB=D0=B5=D0=BA=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Lectures/LectureServiceTests.cs | 83 +++++++++- .../Interfaces/INotificationScheduler.cs | 8 +- .../Notifications/NotificationService.cs | 2 +- .../QuartzNotificationScheduler.cs | 18 ++- .../Services/LectureService.cs | 147 +++++++++++++++++- frontend/src/views/student/CatalogView.vue | 2 +- 6 files changed, 250 insertions(+), 10 deletions(-) diff --git a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs index 9f93fc8..09ca7b2 100644 --- a/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Lectures/LectureServiceTests.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using NSubstitute; using UniVerse.Application.DTOs.Lectures; +using UniVerse.Application.DTOs.Notifications; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; using UniVerse.Infrastructure.Data; @@ -15,7 +16,7 @@ public class LectureServiceTests public async Task GetAllAsync_MarksLecturesEnrolledByCurrentUser() { await using var db = CreateDbContext(); - var service = new LectureService(db, Substitute.For()); + var service = new LectureService(db, Substitute.For(), Substitute.For()); var startsAt = DateTime.UtcNow.AddDays(1); db.Users.Add(new User { Id = 1, Email = "student@test.local" }); @@ -32,6 +33,86 @@ public class LectureServiceTests Assert.False(result.Items.Single(item => item.Id == 2).IsEnrolled); } + [Fact] + public async Task EnrollAsync_SchedulesLectureReminders() + { + await using var db = CreateDbContext(); + var scheduler = Substitute.For(); + var service = new LectureService(db, Substitute.For(), scheduler); + var startsAt = DateTime.UtcNow.AddHours(4); + + db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(1, startsAt)); + await db.SaveChangesAsync(); + + await service.EnrollAsync(1, 1); + + await scheduler.Received(1).ScheduleAsync( + Arg.Is(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 3 часа")), + Arg.Is(d => d == new DateTimeOffset(startsAt.AddHours(-3))), + "lecture-1-user-1-starts-in-3-hours", + Arg.Any()); + await scheduler.Received(1).ScheduleAsync( + Arg.Is(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 1 час")), + Arg.Is(d => d == new DateTimeOffset(startsAt.AddHours(-1))), + "lecture-1-user-1-starts-in-1-hour", + Arg.Any()); + await scheduler.Received(1).ScheduleAsync( + Arg.Is(m => m.Recipient == "student@test.local" && m.Subject.Contains("Оцените")), + Arg.Is(d => d == new DateTimeOffset(startsAt.AddHours(2))), + "lecture-1-user-1-ended", + Arg.Any()); + } + + [Fact] + public async Task EnrollAsync_SkipsPastLectureReminders() + { + await using var db = CreateDbContext(); + var scheduler = Substitute.For(); + var service = new LectureService(db, Substitute.For(), scheduler); + var startsAt = DateTime.UtcNow.AddMinutes(90); + + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(1, startsAt)); + await db.SaveChangesAsync(); + + await service.EnrollAsync(1, 1); + + await scheduler.DidNotReceive().ScheduleAsync( + Arg.Any(), + Arg.Any(), + "lecture-1-user-1-starts-in-3-hours", + Arg.Any()); + await scheduler.Received(2).ScheduleAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task UnenrollAsync_CancelsLectureReminders() + { + await using var db = CreateDbContext(); + var scheduler = Substitute.For(); + var service = new LectureService(db, Substitute.For(), scheduler); + var startsAt = DateTime.UtcNow.AddHours(4); + + db.Users.Add(new User { Id = 1, Email = "student@test.local" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(Lecture(1, startsAt)); + db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 }); + await db.SaveChangesAsync(); + + await service.UnenrollAsync(1, 1); + + await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-3-hours", Arg.Any()); + await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-1-hour", Arg.Any()); + await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any()); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() diff --git a/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs b/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs index 0dde445..518696b 100644 --- a/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs +++ b/backend/UniVerse.Application/Interfaces/INotificationScheduler.cs @@ -4,5 +4,11 @@ namespace UniVerse.Application.Interfaces; public interface INotificationScheduler { - Task ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default); + Task ScheduleAsync( + NotificationMessage message, + DateTimeOffset sendAt, + string? jobId = null, + CancellationToken cancellationToken = default); + + Task CancelAsync(string jobId, CancellationToken cancellationToken = default); } diff --git a/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs index ca60efd..7cfc674 100644 --- a/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs +++ b/backend/UniVerse.Infrastructure/Notifications/NotificationService.cs @@ -51,7 +51,7 @@ public class NotificationService : INotificationService request.RecipientName, request.Metadata); - return _scheduler.ScheduleAsync(message, request.SendAt, cancellationToken); + return _scheduler.ScheduleAsync(message, request.SendAt, cancellationToken: cancellationToken); } public async Task CreateUserNotificationAsync( diff --git a/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs b/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs index 1f457ef..1a264cb 100644 --- a/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs +++ b/backend/UniVerse.Infrastructure/Notifications/QuartzNotificationScheduler.cs @@ -19,13 +19,17 @@ public class QuartzNotificationScheduler : INotificationScheduler _logger = logger; } - public async Task ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default) + public async Task ScheduleAsync( + NotificationMessage message, + DateTimeOffset sendAt, + string? jobId = null, + CancellationToken cancellationToken = default) { if (sendAt <= DateTimeOffset.UtcNow) throw new ArgumentException("Scheduled notification time must be in the future.", nameof(sendAt)); var scheduler = await _schedulerFactory.GetScheduler(cancellationToken); - var jobId = Guid.NewGuid().ToString("N"); + jobId ??= Guid.NewGuid().ToString("N"); var jobKey = new JobKey(jobId, NotificationGroup); var payload = JsonSerializer.Serialize(message); @@ -45,4 +49,14 @@ public class QuartzNotificationScheduler : INotificationScheduler return new ScheduledNotificationResponse(jobId, sendAt); } + + public async Task CancelAsync(string jobId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + + var scheduler = await _schedulerFactory.GetScheduler(cancellationToken); + var deleted = await scheduler.DeleteJob(new JobKey(jobId, NotificationGroup), cancellationToken); + if (deleted) + _logger.LogInformation("Cancelled notification job {JobId}", jobId); + } } diff --git a/backend/UniVerse.Infrastructure/Services/LectureService.cs b/backend/UniVerse.Infrastructure/Services/LectureService.cs index 8674d10..c3935a3 100644 --- a/backend/UniVerse.Infrastructure/Services/LectureService.cs +++ b/backend/UniVerse.Infrastructure/Services/LectureService.cs @@ -1,6 +1,7 @@ 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; @@ -13,11 +14,16 @@ public class LectureService : ILectureService { private readonly AppDbContext _db; private readonly IGamificationService _gamification; + private readonly INotificationScheduler _notificationScheduler; - public LectureService(AppDbContext db, IGamificationService gamification) + public LectureService( + AppDbContext db, + IGamificationService gamification, + INotificationScheduler notificationScheduler) { _db = db; _gamification = gamification; + _notificationScheduler = notificationScheduler; } private IQueryable BaseQuery() => _db.Lectures @@ -77,28 +83,42 @@ public class LectureService : ILectureService public async Task UpdateAsync(int id, UpdateLectureRequest req) { - var lecture = await _db.Lectures.FindAsync(id) ?? throw new NotFoundException("Lecture", id); + 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.FindAsync(id) ?? throw new NotFoundException("Lecture", 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.Enrollments) + 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."); @@ -106,6 +126,7 @@ public class LectureService : ILectureService 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); } @@ -114,6 +135,7 @@ public class LectureService : ILectureService 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(); } @@ -138,4 +160,121 @@ public class LectureService : ILectureService .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); } diff --git a/frontend/src/views/student/CatalogView.vue b/frontend/src/views/student/CatalogView.vue index efec227..8031fd7 100644 --- a/frontend/src/views/student/CatalogView.vue +++ b/frontend/src/views/student/CatalogView.vue @@ -104,7 +104,7 @@ const calendarGroups = computed(() => { async function registerLecture(id: string) { try { await lecturesStore.register(id) - addToast?.('Вы записаны на лекцию. Напоминание придет за сутки.', 'success') + addToast?.('Вы записаны на лекцию.', 'success') } catch (err) { addToast?.(err instanceof Error ? err.message : 'Не удалось записаться на лекцию.', 'error') }