using System.ComponentModel.DataAnnotations; using System.Data; using System.Net; using System.Text.RegularExpressions; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/notifications")] public sealed class NotificationsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, ILogger? logger = null) : ControllerBase { private const int MaximumSelectedRecipients = 500; private const int NotificationBatchSize = 300; private const string Senders = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Counselor + "," + SystemRoles.Teacher; [HttpGet] public async Task GetNotifications( bool? unreadOnly, NotificationCategory? category, string? keyword, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); var userId = currentUserDataScope.Current.UserId; var source = db.Notifications.AsNoTracking() .Where(x => x.UserId == userId); if (unreadOnly == true) source = source.Where(x => !x.IsRead); if (category.HasValue) source = source.Where(x => x.Category == category); if (!string.IsNullOrWhiteSpace(keyword)) { var value = keyword.Trim(); source = source.Where(x => x.Title.Contains(value) || x.Content.Contains(value)); } var total = await source.CountAsync(cancellationToken); var items = await source .OrderByDescending(x => x.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.Id, x.Title, x.Content, x.Category, x.IsRead, x.LinkUrl, SenderName = x.MessageDispatch == null ? "教务系统" : x.MessageDispatch.SenderName, AudienceName = x.MessageDispatch == null ? null : x.MessageDispatch.AudienceName, IsManual = x.MessageDispatchId != null, x.CreatedAt }) .ToListAsync(cancellationToken); var unreadCount = await db.Notifications .CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken); var unreadByCategory = await db.Notifications.AsNoTracking() .Where(x => x.UserId == userId && !x.IsRead) .GroupBy(x => x.Category) .Select(x => new { Category = x.Key, Count = x.Count() }) .ToListAsync(cancellationToken); return Ok(new { Items = items, Total = total, UnreadCount = unreadCount, UnreadByCategory = unreadByCategory }); } [HttpGet("unread-count")] public async Task GetUnreadCount(CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var count = await db.Notifications .CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken); return Ok(new { Count = count }); } [HttpGet("composer")] [Authorize(Roles = Senders)] public async Task GetComposer(CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; if (IsSchoolAdministrator(scope)) { return Ok(await BuildAdministratorComposerAsync( scope, null, MessageAudienceType.School, "全校已启用账号", cancellationToken)); } if (scope.IsInRole(SystemRoles.CollegeAdmin) || scope.IsInRole(SystemRoles.Counselor)) { var collegeId = await ResolveSenderCollegeIdAsync( scope, cancellationToken); if (!collegeId.HasValue) { return ConflictProblem( "当前账号未关联学院,暂时无法确定发信范围。"); } var college = await db.Colleges.AsNoTracking() .Where(x => x.Id == collegeId.Value) .Select(x => x.Name) .FirstOrDefaultAsync(cancellationToken); if (college is null) return ConflictProblem("当前账号关联的学院不存在。"); return Ok(await BuildAdministratorComposerAsync( scope, collegeId.Value, MessageAudienceType.College, $"{college}全院成员", cancellationToken)); } if (scope.IsInRole(SystemRoles.Teacher)) { var tasks = await db.TeachingTasks.AsNoTracking() .Where(task => task.Status != TeachingTaskStatus.Draft && task.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)) .OrderByDescending(task => task.AcademicTerm!.IsCurrent) .ThenByDescending(task => task.AcademicTerm!.StartDate) .ThenBy(task => task.TaskNumber) .Select(task => new { task.Id, task.TaskNumber, CourseName = task.Course!.Name, TermName = task.AcademicTerm!.Name, RecipientCount = db.Students.Count(student => student.Status == StudentStatus.Active && student.UserId != null && (task.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId) || db.CourseEnrollments.Any(enrollment => enrollment.StudentId == student.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering! .TeachingTaskId == task.Id))) }) .ToListAsync(cancellationToken); return Ok(new { AudienceType = MessageAudienceType.TeachingTask, AudienceName = "所带教学班", RecipientCount = 0, TeachingTasks = tasks }); } return Forbid(); } [HttpGet("recipients")] [Authorize(Roles = Senders)] public async Task GetRecipients( Guid? collegeId, string? role, Guid? administrativeClassId, Guid? teachingTaskId, string? keyword, int page = 1, int pageSize = 30, CancellationToken cancellationToken = default) { var scope = currentUserDataScope.Current; if (scope.IsInRole(SystemRoles.Teacher) && !IsSchoolAdministrator(scope) && !scope.IsInRole(SystemRoles.CollegeAdmin) && !scope.IsInRole(SystemRoles.Counselor)) { return Forbid(); } var resolvedCollegeId = IsSchoolAdministrator(scope) ? null : await ResolveSenderCollegeIdAsync(scope, cancellationToken); if (!IsSchoolAdministrator(scope) && !resolvedCollegeId.HasValue) return ConflictProblem("当前账号未关联学院,暂时无法确定收件人范围。"); var filter = new MessageRecipientFilter( collegeId, Normalize(role), administrativeClassId, teachingTaskId, Normalize(keyword)); if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role)) return ValidationProblem("所选身份类型无效。"); page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); var source = ApplyRecipientFilter( ScopedRecipientQuery(scope, resolvedCollegeId), filter); var total = await source.CountAsync(cancellationToken); var users = await source .OrderBy(x => x.DisplayName) .ThenBy(x => x.UserName) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(user => new { user.Id, user.DisplayName, user.UserName, user.StaffNumber, CollegeName = db.Colleges .Where(college => college.Id == user.CollegeId) .Select(college => college.Name) .FirstOrDefault(), StudentNumber = db.Students .Where(student => student.UserId == user.Id) .Select(student => student.StudentNumber) .FirstOrDefault(), ClassName = db.Students .Where(student => student.UserId == user.Id) .Select(student => student.AdministrativeClass!.Name) .FirstOrDefault(), TeacherNumber = db.Teachers .Where(teacher => teacher.UserId == user.Id) .Select(teacher => teacher.TeacherNumber) .FirstOrDefault() }) .ToListAsync(cancellationToken); var userIds = users.Select(x => x.Id).ToArray(); var roleRows = await db.UserRoles.AsNoTracking() .Where(x => userIds.Contains(x.UserId)) .Join( db.Roles.AsNoTracking(), userRole => userRole.RoleId, roleEntity => roleEntity.Id, (userRole, roleEntity) => new { userRole.UserId, RoleName = roleEntity.Name! }) .ToListAsync(cancellationToken); var rolesByUser = roleRows .GroupBy(x => x.UserId) .ToDictionary( group => group.Key, group => group.Select(x => x.RoleName).Distinct().ToArray()); return Ok(new { Items = users.Select(user => new { user.Id, user.DisplayName, user.UserName, Number = user.StudentNumber ?? user.TeacherNumber ?? user.StaffNumber, user.CollegeName, user.ClassName, Roles = rolesByUser.GetValueOrDefault(user.Id, []) }), Total = total, Page = page, PageSize = pageSize }); } [HttpPost("send")] [Authorize(Roles = Senders)] public async Task Send( SendMessageRequest request, CancellationToken cancellationToken) { var title = request.Title.Trim(); var content = request.Content.Trim(); if (title.Length == 0) return ValidationProblem("请填写消息标题。"); if (PlainText(content).Length == 0) return ValidationProblem("请填写消息正文。"); if (content.Length > 20000) return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。"); var scope = currentUserDataScope.Current; MessageAudienceType audienceType; Guid? audienceId = null; string audienceName; List recipientIds; if (IsSchoolAdministrator(scope)) { var result = await ResolveAdministratorRecipientsAsync( scope, null, request, "全校已启用账号", cancellationToken); if (result.Error is not null) return result.Error; audienceType = result.AudienceType; audienceName = result.AudienceName!; recipientIds = result.RecipientIds!; } else if (scope.IsInRole(SystemRoles.CollegeAdmin) || scope.IsInRole(SystemRoles.Counselor)) { var collegeId = await ResolveSenderCollegeIdAsync( scope, cancellationToken); if (!collegeId.HasValue) { return ConflictProblem( "当前账号未关联学院,暂时无法确定发信范围。"); } var collegeName = await db.Colleges.AsNoTracking() .Where(x => x.Id == collegeId.Value) .Select(x => x.Name) .FirstOrDefaultAsync(cancellationToken); if (collegeName is null) return ConflictProblem("当前账号关联的学院不存在。"); var result = await ResolveAdministratorRecipientsAsync( scope, collegeId.Value, request, $"{collegeName}全院成员", cancellationToken); if (result.Error is not null) return result.Error; audienceType = result.AudienceType; audienceId = audienceType == MessageAudienceType.College ? collegeId.Value : null; audienceName = result.AudienceName!; recipientIds = result.RecipientIds!; } else if (scope.IsInRole(SystemRoles.Teacher)) { if (!request.TeachingTaskId.HasValue) return ValidationProblem("请选择接收消息的教学班。"); var task = await db.TeachingTasks.AsNoTracking() .Where(x => x.Id == request.TeachingTaskId.Value && x.Status != TeachingTaskStatus.Draft && x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)) .Select(x => new { x.Id, x.TaskNumber, CourseName = x.Course!.Name }) .FirstOrDefaultAsync(cancellationToken); if (task is null) { return ConflictProblem( "只能向自己所带且已发布的教学班发信。"); } audienceType = MessageAudienceType.TeachingTask; audienceId = task.Id; audienceName = $"{task.TaskNumber} · {task.CourseName}"; recipientIds = await GetTeachingTaskRecipientIdsAsync( task.Id, cancellationToken); } else { return Forbid(); } recipientIds = recipientIds.Distinct().ToList(); if (recipientIds.Count == 0) return ConflictProblem("当前发信范围内没有可接收消息的已启用账号。"); var dispatch = new MessageDispatch { SenderUserId = scope.UserId, SenderName = scope.DisplayName ?? "教务用户", Title = title, Content = content, Category = NotificationCategory.General, AudienceType = audienceType, AudienceId = audienceId, AudienceName = audienceName, RecipientCount = recipientIds.Count, LinkUrl = null }; try { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.MessageDispatches.Add(dispatch); await db.SaveChangesAsync(cancellationToken); db.ChangeTracker.Clear(); foreach (var batch in recipientIds.Chunk(NotificationBatchSize)) { db.Notifications.AddRange(batch.Select(userId => new Notification { UserId = userId, Title = title, Content = content, Category = NotificationCategory.General, LinkUrl = null, MessageDispatchId = dispatch.Id })); await db.SaveChangesAsync(cancellationToken); db.ChangeTracker.Clear(); } await transaction.CommitAsync(cancellationToken); return Ok(new { dispatch.Id, dispatch.AudienceName, dispatch.RecipientCount, dispatch.CreatedAt }); }, cancellationToken, IsolationLevel.ReadCommitted); } catch (DbUpdateException exception) { logger?.LogError( exception, "Failed to send notification dispatch {DispatchId} to {RecipientCount} recipients.", dispatch.Id, recipientIds.Count); return Problem( title: "消息未能发送", detail: "消息数据保存失败。请确认数据库已完成最新升级后重试;本次消息没有发送。", statusCode: StatusCodes.Status503ServiceUnavailable); } } [HttpGet("sent")] [Authorize(Roles = Senders)] public async Task GetSent( int page = 1, int pageSize = 20, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); var userId = currentUserDataScope.Current.UserId; var source = db.MessageDispatches.AsNoTracking() .Where(x => x.SenderUserId == userId); var total = await source.CountAsync(cancellationToken); var items = await source .OrderByDescending(x => x.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.Id, x.Title, x.Content, x.Category, x.AudienceType, x.AudienceName, x.RecipientCount, x.CreatedAt }) .ToListAsync(cancellationToken); return Ok(new { Items = items, Total = total }); } [HttpPost("{id:guid}/read")] public async Task MarkRead( Guid id, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var notification = await db.Notifications .FirstOrDefaultAsync( x => x.Id == id && x.UserId == userId, cancellationToken); if (notification is null) return NotFound(); notification.IsRead = true; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("read-all")] public async Task MarkAllRead( CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; await db.Notifications .Where(x => x.UserId == userId && !x.IsRead) .ExecuteUpdateAsync( updates => updates.SetProperty(x => x.IsRead, true), cancellationToken); return NoContent(); } private async Task> GetTeachingTaskRecipientIdsAsync( Guid teachingTaskId, CancellationToken cancellationToken) { return await db.Students.AsNoTracking() .Where(student => student.Status == StudentStatus.Active && student.UserId != null && db.Users.Any(user => user.Id == student.UserId && user.IsEnabled) && (db.TeachingTaskClasses.Any(item => item.TeachingTaskId == teachingTaskId && item.AdministrativeClassId == student.AdministrativeClassId) || db.CourseEnrollments.Any(enrollment => enrollment.StudentId == student.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId))) .Select(x => x.UserId!.Value) .Distinct() .ToListAsync(cancellationToken); } private async Task ResolveSenderCollegeIdAsync( CurrentUserScope scope, CancellationToken cancellationToken) { if (scope.CollegeId.HasValue) return scope.CollegeId; if (!scope.IsInRole(SystemRoles.Counselor)) return null; var collegeIds = await db.AdministrativeClasses.AsNoTracking() .Where(x => x.CounselorUserId == scope.UserId) .Select(x => x.Major!.CollegeId) .Distinct() .Take(2) .ToListAsync(cancellationToken); return collegeIds.Count == 1 ? collegeIds[0] : null; } private static bool IsSchoolAdministrator(CurrentUserScope scope) => scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.AcademicAdmin); private async Task BuildAdministratorComposerAsync( CurrentUserScope scope, Guid? fixedCollegeId, MessageAudienceType audienceType, string audienceName, CancellationToken cancellationToken) { var colleges = await db.Colleges.AsNoTracking() .Where(x => !fixedCollegeId.HasValue || x.Id == fixedCollegeId.Value) .OrderBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name }) .ToListAsync(cancellationToken); var administrativeClasses = await db.AdministrativeClasses.AsNoTracking() .Where(x => x.IsEnabled && (!fixedCollegeId.HasValue || x.Major!.CollegeId == fixedCollegeId.Value)) .OrderByDescending(x => x.Grade) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.Grade, CollegeId = x.Major!.CollegeId }) .ToListAsync(cancellationToken); var teachingTasks = await db.TeachingTasks.AsNoTracking() .Where(x => x.Status != TeachingTaskStatus.Draft && (!fixedCollegeId.HasValue || x.Course!.CollegeId == fixedCollegeId.Value)) .OrderByDescending(x => x.AcademicTerm!.IsCurrent) .ThenByDescending(x => x.AcademicTerm!.StartDate) .ThenBy(x => x.TaskNumber) .Select(x => new { x.Id, x.TaskNumber, CourseName = x.Course!.Name, TermName = x.AcademicTerm!.Name, CollegeId = x.Course.CollegeId }) .ToListAsync(cancellationToken); var recipientCount = await ScopedRecipientQuery(scope, fixedCollegeId) .CountAsync(cancellationToken); return new { AudienceType = audienceType, AudienceName = audienceName, RecipientCount = recipientCount, CanFilterRecipients = true, MaximumSelectedRecipients, Colleges = colleges, Roles = RoleOptions, AdministrativeClasses = administrativeClasses, TeachingTasks = teachingTasks }; } private IQueryable ScopedRecipientQuery( CurrentUserScope scope, Guid? fixedCollegeId) { var source = db.Users.AsNoTracking() .Where(x => x.IsEnabled && x.Id != scope.UserId); if (IsSchoolAdministrator(scope)) return source; if (!fixedCollegeId.HasValue) return source.Where(_ => false); var collegeId = fixedCollegeId.Value; return source.Where(user => user.CollegeId == collegeId || db.Students.Any(student => student.UserId == user.Id && student.AdministrativeClass!.Major!.CollegeId == collegeId) || db.Teachers.Any(teacher => teacher.UserId == user.Id && teacher.CollegeId == collegeId)); } private IQueryable ApplyRecipientFilter( IQueryable source, MessageRecipientFilter? filter) { if (filter is null) return source; if (filter.CollegeId.HasValue) { var collegeId = filter.CollegeId.Value; source = source.Where(user => user.CollegeId == collegeId || db.Students.Any(student => student.UserId == user.Id && student.AdministrativeClass!.Major!.CollegeId == collegeId) || db.Teachers.Any(teacher => teacher.UserId == user.Id && teacher.CollegeId == collegeId)); } if (!string.IsNullOrWhiteSpace(filter.Role)) { var role = filter.Role; source = source.Where(user => db.UserRoles.Any(userRole => userRole.UserId == user.Id && db.Roles.Any(roleEntity => roleEntity.Id == userRole.RoleId && roleEntity.Name == role))); } if (filter.AdministrativeClassId.HasValue) { var classId = filter.AdministrativeClassId.Value; source = source.Where(user => db.Students.Any(student => student.UserId == user.Id && student.AdministrativeClassId == classId)); } if (filter.TeachingTaskId.HasValue) { var taskId = filter.TeachingTaskId.Value; source = source.Where(user => db.Students.Any(student => student.UserId == user.Id && (db.TeachingTaskClasses.Any(item => item.TeachingTaskId == taskId && item.AdministrativeClassId == student.AdministrativeClassId) || db.CourseEnrollments.Any(enrollment => enrollment.StudentId == student.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering!.TeachingTaskId == taskId)))); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword; source = source.Where(user => user.DisplayName.Contains(keyword) || (user.UserName != null && user.UserName.Contains(keyword)) || (user.StaffNumber != null && user.StaffNumber.Contains(keyword)) || db.Students.Any(student => student.UserId == user.Id && (student.Name.Contains(keyword) || student.StudentNumber.Contains(keyword))) || db.Teachers.Any(teacher => teacher.UserId == user.Id && (teacher.Name.Contains(keyword) || teacher.TeacherNumber.Contains(keyword)))); } return source; } private async Task ResolveAdministratorRecipientsAsync( CurrentUserScope scope, Guid? fixedCollegeId, SendMessageRequest request, string defaultAudienceName, CancellationToken cancellationToken) { var source = ScopedRecipientQuery(scope, fixedCollegeId); if (request.RecipientMode == MessageRecipientMode.Scope) { return new RecipientResolution( fixedCollegeId.HasValue ? MessageAudienceType.College : MessageAudienceType.School, defaultAudienceName, await source.Select(x => x.Id).ToListAsync(cancellationToken), null); } if (request.RecipientMode == MessageRecipientMode.Filtered) { if (request.RecipientFilter?.Role is not null && !SystemRoles.All.Contains(request.RecipientFilter.Role)) { return new RecipientResolution( default, null, null, ValidationProblem("所选身份类型无效。")); } var recipientIds = await ApplyRecipientFilter( source, request.RecipientFilter) .Select(x => x.Id) .ToListAsync(cancellationToken); return new RecipientResolution( MessageAudienceType.Custom, BuildFilteredAudienceName(request.RecipientFilter), recipientIds, null); } if (request.RecipientMode == MessageRecipientMode.Selected) { var requestedIds = request.RecipientUserIds? .Distinct() .ToArray() ?? []; if (requestedIds.Length == 0) { return new RecipientResolution( default, null, null, ValidationProblem("请至少选择一名收件人。")); } if (requestedIds.Length > MaximumSelectedRecipients) { return new RecipientResolution( default, null, null, ValidationProblem( $"单次最多指定 {MaximumSelectedRecipients} 名收件人。")); } var authorizedIds = await source .Where(x => requestedIds.Contains(x.Id)) .Select(x => x.Id) .ToListAsync(cancellationToken); if (authorizedIds.Count != requestedIds.Length) { return new RecipientResolution( default, null, null, ValidationProblem("所选收件人包含无权限访问或已停用的账号。")); } return new RecipientResolution( MessageAudienceType.Custom, $"指定收件人({authorizedIds.Count} 人)", authorizedIds, null); } return new RecipientResolution( default, null, null, ValidationProblem("请选择有效的收件方式。")); } private static string BuildFilteredAudienceName(MessageRecipientFilter? filter) { if (filter is null) return "当前权限范围内全部账号"; var parts = new List(); if (filter.CollegeId.HasValue) parts.Add("指定学院"); if (filter.Role is not null) parts.Add(RoleOptions.FirstOrDefault(x => x.Value == filter.Role)?.Label ?? filter.Role); if (filter.AdministrativeClassId.HasValue) parts.Add("指定行政班"); if (filter.TeachingTaskId.HasValue) parts.Add("指定教学班"); if (filter.Keyword is not null) parts.Add($"关键词“{filter.Keyword}”"); return parts.Count == 0 ? "当前权限范围内全部账号" : string.Join(" · ", parts); } private static string PlainText(string html) { var withoutTags = Regex.Replace(html, "<[^>]+>", " "); return WebUtility.HtmlDecode(withoutTags).Trim(); } private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static readonly RoleOption[] RoleOptions = [ new(SystemRoles.Student, "学生"), new(SystemRoles.Teacher, "任课教师"), new(SystemRoles.Counselor, "辅导员"), new(SystemRoles.CollegeAdmin, "学院管理员"), new(SystemRoles.AcademicAdmin, "校级教务管理员"), new(SystemRoles.Leader, "校领导"), new(SystemRoles.SuperAdmin, "超级管理员") ]; private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法发送消息", Detail = detail, Status = StatusCodes.Status409Conflict }); private ActionResult ValidationProblem(string detail) => BadRequest(new ProblemDetails { Title = "消息内容不完整", Detail = detail, Status = StatusCodes.Status400BadRequest }); } public sealed record SendMessageRequest( [Required, StringLength(200)] string Title, [Required, StringLength(20000)] string Content, Guid? TeachingTaskId = null, MessageRecipientMode RecipientMode = MessageRecipientMode.Scope, MessageRecipientFilter? RecipientFilter = null, IReadOnlyCollection? RecipientUserIds = null); public sealed record MessageRecipientFilter( Guid? CollegeId = null, string? Role = null, Guid? AdministrativeClassId = null, Guid? TeachingTaskId = null, string? Keyword = null); public enum MessageRecipientMode { Scope = 1, Filtered = 2, Selected = 3 } public sealed record RoleOption(string Value, string Label); internal sealed record RecipientResolution( MessageAudienceType AudienceType, string? AudienceName, List? RecipientIds, ActionResult? Error); /// /// Centralized helper to send notifications across the app. /// public static class NotificationService { public static async Task SendAsync( AppDbContext db, Guid userId, string title, string content, string? linkUrl = null, CancellationToken cancellationToken = default, NotificationCategory category = NotificationCategory.General) { db.Notifications.Add(new Notification { UserId = userId, Title = title, Content = content, Category = category, LinkUrl = linkUrl }); await db.SaveChangesAsync(cancellationToken); } public static async Task SendToRoleAsync( AppDbContext db, string roleName, string title, string content, Guid? collegeId = null, string? linkUrl = null, CancellationToken cancellationToken = default, NotificationCategory category = NotificationCategory.General) { var query = db.Users .Join( db.UserRoles, user => user.Id, userRole => userRole.UserId, (user, userRole) => new { user.Id, user.IsEnabled, user.CollegeId, userRole.RoleId }) .Join( db.Roles, item => item.RoleId, role => role.Id, (item, role) => new { item.Id, item.IsEnabled, item.CollegeId, RoleName = role.Name! }); var userIds = query.Where(x => x.RoleName == roleName && x.IsEnabled); if (collegeId.HasValue) { userIds = userIds.Where(x => x.CollegeId == collegeId.Value); } var ids = await userIds .Select(x => x.Id) .Distinct() .ToListAsync(cancellationToken); foreach (var id in ids) { db.Notifications.Add(new Notification { UserId = id, Title = title, Content = content, Category = category, LinkUrl = linkUrl }); } await db.SaveChangesAsync(cancellationToken); } public static async Task SendToUserIdsAsync( AppDbContext db, IEnumerable userIds, string title, string content, string? linkUrl = null, CancellationToken cancellationToken = default, NotificationCategory category = NotificationCategory.General) { foreach (var userId in userIds.Distinct()) { db.Notifications.Add(new Notification { UserId = userId, Title = title, Content = content, Category = category, LinkUrl = linkUrl }); } await db.SaveChangesAsync(cancellationToken); } }