feat: добавил напоминания о лекциях
Backend CI / build-and-test (push) Successful in 51s
Frontend CI / build-and-check (push) Failing after 5m11s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 14s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m20s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 23s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s

This commit is contained in:
2026-05-16 11:19:31 +03:00
parent 373e551bea
commit 926688cd2e
6 changed files with 250 additions and 10 deletions
@@ -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<IGamificationService>());
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
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<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), 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<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 3 часа")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-3))),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 1 час")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-1))),
"lecture-1-user-1-starts-in-1-hour",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("Оцените")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(2))),
"lecture-1-user-1-ended",
Arg.Any<CancellationToken>());
}
[Fact]
public async Task EnrollAsync_SkipsPastLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), 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<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(2).ScheduleAsync(
Arg.Any<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task UnenrollAsync_CancelsLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), 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<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-1-hour", Arg.Any<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -4,5 +4,11 @@ namespace UniVerse.Application.Interfaces;
public interface INotificationScheduler
{
Task<ScheduledNotificationResponse> ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default);
Task<ScheduledNotificationResponse> ScheduleAsync(
NotificationMessage message,
DateTimeOffset sendAt,
string? jobId = null,
CancellationToken cancellationToken = default);
Task CancelAsync(string jobId, CancellationToken cancellationToken = default);
}
@@ -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<UserNotificationDto> CreateUserNotificationAsync(
@@ -19,13 +19,17 @@ public class QuartzNotificationScheduler : INotificationScheduler
_logger = logger;
}
public async Task<ScheduledNotificationResponse> ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default)
public async Task<ScheduledNotificationResponse> 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);
}
}
@@ -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<Lecture> BaseQuery() => _db.Lectures
@@ -77,28 +83,42 @@ public class LectureService : ILectureService
public async Task<LectureDto> 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<EnrollmentDto>.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<string, string>
{
["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<LectureReminder> 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<string>
{
$"Напоминаем: лекция \"{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);
}