using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Services.Communications; using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Application.Events; public sealed class LateProjectEvent : IEvent { public Guid Id { get; set; } = Guid.CreateVersion7(); public string Name { get; set; } = nameof(LateProjectEvent); public DateTimeOffset EnqueueAt { get; set; } = DateTimeOffset.UtcNow; public string CorrelationId { get; set; } = "lateproject-reminder"; public static LateProjectEvent Create() => new(); } public sealed class LateProjectEventHandler(IDbContextFactory contextFactory, ICommunicationsService service) : INotificationHandler { readonly int lateDays = 3; public async ValueTask Handle(LateProjectEvent notification, CancellationToken cancellationToken) { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var now = DateTime.UtcNow; var overdueCutoff = lateDays > 0 ? now.AddDays(-lateDays) : now; // Query overdue milestones that are not yet approved // Execute ToListAsync before GroupBy so EF Core does not fail SQL translation var overdueMilestones = await context.Milestones .AsNoTracking() .Include(i => i.Project) .ThenInclude(p => p!.Programme) .ThenInclude(pr => pr!.Owner) .Include(i => i.Project) .ThenInclude(p => p!.Programme) .ThenInclude(pr => pr!.Portfolio) .ThenInclude(po => po!.Organisation) .ThenInclude(o => o!.User) .Where(m => m.Status != ApprovalStatus.Approved && m.DueAt <= overdueCutoff) .OrderBy(o => o.DueAt) .ToListAsync(cancellationToken); if (overdueMilestones.Count == 0) return; var lateMilestonesByProject = overdueMilestones.GroupBy(g => g.ProjectId); var partnerResourceStream = typeof(IDomain).Assembly .GetManifestResourceStream("PostFundManagement.Domain.Templates.lateprojectsmilestones.html"); if (partnerResourceStream is null) return; using var partnerHtmlTemplateReader = new StreamReader(partnerResourceStream); var partnerTemplate = await partnerHtmlTemplateReader.ReadToEndAsync(cancellationToken); var programmeResourceStream = typeof(IDomain).Assembly .GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects-prog.html"); string? programmeTemplate = null; if (programmeResourceStream is not null) { using var programmeHtmlTemplateReader = new StreamReader(programmeResourceStream); programmeTemplate = await programmeHtmlTemplateReader.ReadToEndAsync(cancellationToken); } foreach (var group in lateMilestonesByProject) { var milestones = group.ToList(); if (milestones.Count == 0) continue; var project = milestones[0].Project; if (project is null) continue; var mostLateMilestone = milestones.OrderBy(m => m.DueAt).FirstOrDefault(); var daysOverdue = mostLateMilestone != null ? (int)Math.Max(0, (now - mostLateMilestone.DueAt).TotalDays) : 0; var organisation = mostLateMilestone?.Project?.Programme?.Portfolio?.Organisation; if (organisation is null) continue; var recipientId = organisation.UserId ?? organisation.CreatedBy; var recipientEmail = organisation.User?.Email ?? organisation.Email; if (string.IsNullOrWhiteSpace(recipientEmail) || recipientId == Guid.Empty) continue; var tableRowsBuilder = new StringBuilder(); foreach (var milestone in milestones.OrderBy(m => m.DueAt)) { var delay = (int)Math.Max(0, (now - milestone.DueAt).TotalDays); var statusText = milestone.Status.ToString(); var milestoneName = WebUtility.HtmlEncode(milestone.Name ?? "Milestone Deliverable"); tableRowsBuilder.AppendLine($@" {milestoneName} {milestone.DueAt:yyyy-MM-dd} +{delay} d {statusText} "); } // 1. Send notice to Partner / Recipient Organisation var messageBody = partnerTemplate; messageBody = messageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner")); messageBody = messageBody.Replace("[RecipientName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner")); messageBody = messageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project")); messageBody = messageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified"); messageBody = messageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified"); messageBody = messageBody.Replace("[TotalLateMilestones]", milestones.Count.ToString()); messageBody = messageBody.Replace("[TotalLateMiletones]", milestones.Count.ToString()); messageBody = messageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(mostLateMilestone?.Name ?? "N/A")); messageBody = messageBody.Replace("[MostLateMilestoneDueDate]", mostLateMilestone != null ? $"{mostLateMilestone.DueAt:yyyy-MM-dd}" : "N/A"); messageBody = messageBody.Replace("[MostLateMilestoneDaysOverdue]", daysOverdue.ToString()); messageBody = messageBody.Replace("[LateMilestonesTable]", tableRowsBuilder.ToString()); messageBody = messageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za"); messageBody = messageBody.Replace("[Year]", now.Year.ToString()); var notificationRequest = new CreateNotificationRequest(organisation.Id, recipientId, NotificationPlatform.Email, $"Overdue Milestone Alert: {project.Name}", recipientEmail, messageBody, NotificationStatus.Queued); _ = await service.CreateNotificationAsync(notificationRequest, SystemUser, cancellationToken); // 2. Construct and send escalation notification for the Programme Manager (FR-M07-009) if (!string.IsNullOrWhiteSpace(programmeTemplate)) { var programme = project.Programme; var programmeManager = programme?.Owner; var programmeManagerId = programme?.OwnedBy ?? programmeManager?.Id; var programmeManagerEmail = programmeManager?.Email; if (programmeManagerId.HasValue && programmeManagerId.Value != Guid.Empty && !string.IsNullOrWhiteSpace(programmeManagerEmail)) { var progMessageBody = programmeTemplate; progMessageBody = progMessageBody.Replace("[ProgrammeManagerName]", WebUtility.HtmlEncode(programmeManagerEmail ?? "Programme Manager")); progMessageBody = progMessageBody.Replace("[ProgrammeName]", WebUtility.HtmlEncode(programme?.Name ?? "Programme")); progMessageBody = progMessageBody.Replace("[PortfolioName]", WebUtility.HtmlEncode(programme?.Portfolio?.Name ?? "Portfolio")); progMessageBody = progMessageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner")); progMessageBody = progMessageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project")); progMessageBody = progMessageBody.Replace("[ProjectStartDate]", project.StartedAt.HasValue ? $"{project.StartedAt:yyyy-MM-dd}" : "Not Specified"); progMessageBody = progMessageBody.Replace("[ProjectEndDate]", project.EndedAt.HasValue ? $"{project.EndedAt:yyyy-MM-dd}" : "Not Specified"); progMessageBody = progMessageBody.Replace("[TotalLateMilestones]", milestones.Count.ToString()); progMessageBody = progMessageBody.Replace("[TotalLateMiletones]", milestones.Count.ToString()); progMessageBody = progMessageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(mostLateMilestone?.Name ?? "N/A")); progMessageBody = progMessageBody.Replace("[MostLateMilestoneDueDate]", mostLateMilestone != null ? $"{mostLateMilestone.DueAt:yyyy-MM-dd}" : "N/A"); progMessageBody = progMessageBody.Replace("[MostLateMilestoneDaysOverdue]", daysOverdue.ToString()); progMessageBody = progMessageBody.Replace("[LateMilestonesTable]", tableRowsBuilder.ToString()); progMessageBody = progMessageBody.Replace("[ConsoleUrl]", "https://console.ifri.khongisa.co.za"); progMessageBody = progMessageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za"); progMessageBody = progMessageBody.Replace("[Year]", now.Year.ToString()); var progNotificationRequest = new CreateNotificationRequest( organisation.Id, programmeManagerId.Value, NotificationPlatform.Email, $"[Escalation] Overdue Milestones: {project.Name} ({programme?.Name})", programmeManagerEmail, progMessageBody, NotificationStatus.Queued); _ = await service.CreateNotificationAsync(progNotificationRequest, SystemUser, cancellationToken); } } } } }