using System.Net; using System.Text; 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 NonCompliantProjectEvent : IEvent { public Guid Id { get; set; } = Guid.CreateVersion7(); public string Name { get; set; } = nameof(NonCompliantProjectEvent); public DateTimeOffset EnqueueAt { get; set; } = DateTimeOffset.UtcNow; public string CorrelationId { get; set; } = "noncompliant-project-check"; public static NonCompliantProjectEvent Create() => new(); } public sealed class NonCompliantProjectEventHandler(IDbContextFactory contextFactory, ICommunicationsService service) : INotificationHandler { public async ValueTask Handle(NonCompliantProjectEvent notification, CancellationToken cancellationToken) { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var now = DateTime.UtcNow; // Fetch active/monitored projects with programme, portfolio, and award organisations var projects = await context.Projects .AsNoTracking() .Include(p => p.Programme) .ThenInclude(pr => pr!.Owner) .Include(p => p.Programme) .ThenInclude(pr => pr!.Portfolio) .ThenInclude(po => po!.Organisation) .ThenInclude(o => o!.User) .Include(p => p.Awards) .ThenInclude(a => a.Organisation) .ThenInclude(o => o!.User) .ToListAsync(cancellationToken); if (projects.Count == 0) return; // Fetch compliance items and milestones to evaluate compliance according to ConsoleUI rules var complianceItems = await context.ComplianceItems .AsNoTracking() .ToListAsync(cancellationToken); var milestones = await context.Milestones .AsNoTracking() .ToListAsync(cancellationToken); var complianceByProject = complianceItems.GroupBy(c => c.ProjectId) .ToDictionary(g => g.Key, g => g.ToList()); var milestonesByProject = milestones.GroupBy(m => m.ProjectId) .ToDictionary(g => g.Key, g => g.ToList()); // Load noncompliantprojects.html template for partner organisations var partnerResourceStream = typeof(IDomain).Assembly .GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects.html"); if (partnerResourceStream is null) return; using var partnerHtmlTemplateReader = new StreamReader(partnerResourceStream); var partnerTemplate = await partnerHtmlTemplateReader.ReadToEndAsync(cancellationToken); // Load noncompliantprojects-prog.html template for programme managers var progResourceStream = typeof(IDomain).Assembly .GetManifestResourceStream("PostFundManagement.Domain.Templates.noncompliantprojects-prog.html"); string? programmeTemplate = null; if (progResourceStream is not null) { using var progHtmlTemplateReader = new StreamReader(progResourceStream); programmeTemplate = await progHtmlTemplateReader.ReadToEndAsync(cancellationToken); } foreach (var project in projects) { complianceByProject.TryGetValue(project.Id, out var pComplianceItems); pComplianceItems ??= []; milestonesByProject.TryGetValue(project.Id, out var pMilestones); pMilestones ??= []; // ConsoleUI Invariant: A project is 100% compliant if unmetCompCount == 0 && overdueMilestones.Count == 0 var unmetCompItems = pComplianceItems.Where(c => c.Status == ComplianceStatus.NotMet).ToList(); var overdueMilestones = pMilestones .Where(m => m.Status != ApprovalStatus.Approved && m.DueAt < now) .OrderBy(m => m.DueAt) .ToList(); var isCompliant = unmetCompItems.Count == 0 && overdueMilestones.Count == 0; if (isCompliant) continue; // Compliance rate calculation var totalEvaluated = pComplianceItems.Count + pMilestones.Count; var totalMet = pComplianceItems.Count(c => c.Status == ComplianceStatus.Met || c.Status == ComplianceStatus.Exceeded) + pMilestones.Count(m => m.Status == ApprovalStatus.Approved); var compliancePercentage = totalEvaluated > 0 ? (int)Math.Round((double)totalMet / totalEvaluated * 100) : 0; // Resolve target partner organisation var organisation = project.Awards.FirstOrDefault()?.Organisation ?? 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; // Construct non-compliance table rows var tableRowsBuilder = new StringBuilder(); foreach (var item in unmetCompItems) { var title = WebUtility.HtmlEncode(item.Title ?? "Statutory Compliance Obligation"); var requirements = WebUtility.HtmlEncode(item.Requirements ?? "Verification evidence required"); tableRowsBuilder.AppendLine($@" Statutory / Legal {title} {requirements} Not Met "); } foreach (var m in overdueMilestones) { var delay = (int)Math.Max(0, (now - m.DueAt).TotalDays); var mName = WebUtility.HtmlEncode(m.Name ?? "Milestone Deliverable"); tableRowsBuilder.AppendLine($@" Milestone SLA {mName} Due: {m.DueAt:yyyy-MM-dd} (+{delay} d) Overdue "); } // 1. Dispatch partner audit notice var messageBody = partnerTemplate; messageBody = messageBody.Replace("[OrganisationName]", WebUtility.HtmlEncode(organisation.Name ?? "Partner")); messageBody = messageBody.Replace("[ProjectName]", WebUtility.HtmlEncode(project.Name ?? "Project")); messageBody = messageBody.Replace("[ProgrammeName]", WebUtility.HtmlEncode(project.Programme?.Name ?? "Programme")); messageBody = messageBody.Replace("[PortfolioName]", WebUtility.HtmlEncode(project.Programme?.Portfolio?.Name ?? "Portfolio")); 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("[ComplianceScore]", $"{compliancePercentage}%"); messageBody = messageBody.Replace("[UnmetComplianceCount]", unmetCompItems.Count.ToString()); messageBody = messageBody.Replace("[UnmetMilestonesCount]", overdueMilestones.Count.ToString()); messageBody = messageBody.Replace("[NonComplianceTable]", tableRowsBuilder.ToString()); messageBody = messageBody.Replace("[PortalUrl]", "https://partner.ifri.khongisa.co.za"); messageBody = messageBody.Replace("[ConsoleUrl]", "https://console.ifri.khongisa.co.za"); messageBody = messageBody.Replace("[Year]", now.Year.ToString()); var notificationRequest = new CreateNotificationRequest( organisation.Id, recipientId, NotificationPlatform.Email, $"[Audit Notice] Project Non-Compliance: {project.Name}", recipientEmail, messageBody, NotificationStatus.Queued); _ = await service.CreateNotificationAsync(notificationRequest, SystemUser, cancellationToken); // 2. Dispatch internal escalation to Programme Manager (FR-M07-009 / M10) 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]", overdueMilestones.Count.ToString()); progMessageBody = progMessageBody.Replace("[TotalLateMiletones]", overdueMilestones.Count.ToString()); progMessageBody = progMessageBody.Replace("[MostLateMilestoneName]", WebUtility.HtmlEncode(overdueMilestones.FirstOrDefault()?.Name ?? "N/A")); progMessageBody = progMessageBody.Replace("[MostLateMilestoneDueDate]", overdueMilestones.FirstOrDefault() != null ? $"{overdueMilestones.First().DueAt:yyyy-MM-dd}" : "N/A"); progMessageBody = progMessageBody.Replace("[MostLateMilestoneDaysOverdue]", overdueMilestones.FirstOrDefault() != null ? $"{(int)Math.Max(0, (now - overdueMilestones.First().DueAt).TotalDays)}" : "0"); 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, $"[Weekly Audit Escalation] Non-Compliant Project: {project.Name} ({programme?.Name})", programmeManagerEmail, progMessageBody, NotificationStatus.Queued); _ = await service.CreateNotificationAsync(progNotificationRequest, SystemUser, cancellationToken); } } } } }