Added scheduler jobs
This commit is contained in:
@@ -19,7 +19,7 @@ public sealed class LateProjectEvent : IEvent
|
||||
public static LateProjectEvent Create() => new();
|
||||
}
|
||||
|
||||
public sealed class LateProjectEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
public sealed class LateProjectEventHandler(IDbContextFactory<ApplicationDbContext> contextFactory,
|
||||
ICommunicationsService service) : INotificationHandler<LateProjectEvent>
|
||||
{
|
||||
readonly int lateDays = 3;
|
||||
@@ -28,50 +28,149 @@ public sealed class LateProjectEventHandler(IDbContextFactory<ApplicationDbConte
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var lateMilestones = await context.Milestones
|
||||
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!.Programme!.Portfolio!.Organisation!.User)
|
||||
.Where(m => (DateTime.UtcNow - m.Project!.StartedAt!).Value.TotalDays < lateDays)
|
||||
.OrderBy(o => o.CreatedAt)
|
||||
.GroupBy(g => g.ProjectId)
|
||||
.AsSplitQuery()
|
||||
.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 (lateMilestones is null) return;
|
||||
if (overdueMilestones.Count == 0) return;
|
||||
|
||||
using var htmlTemplateReader = new StreamReader(Assembly.GetAssembly(typeof(IDomain))?
|
||||
.GetManifestResourceStream("PostFundManagement.Domain.Templates.lateprojectsmilestones.html")!);
|
||||
var lateMilestonesByProject = overdueMilestones.GroupBy(g => g.ProjectId);
|
||||
|
||||
var template = await htmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||
var partnerResourceStream = typeof(IDomain).Assembly
|
||||
.GetManifestResourceStream("PostFundManagement.Domain.Templates.lateprojectsmilestones.html");
|
||||
|
||||
foreach (var group in lateMilestones)
|
||||
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)
|
||||
{
|
||||
if (!group.Any()) continue;
|
||||
using var programmeHtmlTemplateReader = new StreamReader(programmeResourceStream);
|
||||
programmeTemplate = await programmeHtmlTemplateReader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var project = group.FirstOrDefault()?.Project;
|
||||
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 = group.OrderBy(m => m.DueAt).FirstOrDefault();
|
||||
var missedMilestone = group.Where(m => DateTime.UtcNow > m.DueAt).FirstOrDefault();
|
||||
var mostLateMilestone = milestones.OrderBy(m => m.DueAt).FirstOrDefault();
|
||||
|
||||
var messageBody = template;
|
||||
messageBody = messageBody.Replace("[ProjectName]", project.Name);
|
||||
messageBody = messageBody.Replace("[ProjectStartDate]", $"{project.StartedAt:yyyy-MM-dd}");
|
||||
messageBody = messageBody.Replace("[ProjectEndDate]", $"{project.EndedAt:yyyy-MM-dd}");
|
||||
messageBody = messageBody.Replace("[TotalLateMiletones]", $"{group.Count()}");
|
||||
|
||||
// TODO: output information of most late milestone
|
||||
// TODO: output information of milestones already missed
|
||||
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 notificationRequest = new CreateNotificationRequest(organisation.Id, organisation.User!.Id, NotificationPlatform.Email,
|
||||
"Project Status Update", organisation.User!.Email, messageBody, NotificationStatus.Queued);
|
||||
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($@"
|
||||
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{milestoneName}</td>
|
||||
<td style=""padding: 10px 14px; color: #64748b; font-size: 13px;"">{milestone.DueAt:yyyy-MM-dd}</td>
|
||||
<td style=""padding: 10px 14px; color: #dc2626; font-weight: 600; font-size: 13px;"">+{delay} d</td>
|
||||
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">{statusText}</span></td>
|
||||
</tr>");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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<ApplicationDbContext> contextFactory,
|
||||
ICommunicationsService service) : INotificationHandler<NonCompliantProjectEvent>
|
||||
{
|
||||
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($@"
|
||||
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||
<td style=""padding: 10px 14px; font-weight: 600; color: #475569; font-size: 12px;"">Statutory / Legal</td>
|
||||
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{title}</td>
|
||||
<td style=""padding: 10px 14px; color: #64748b; font-size: 13px;"">{requirements}</td>
|
||||
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">Not Met</span></td>
|
||||
</tr>");
|
||||
}
|
||||
|
||||
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($@"
|
||||
<tr style=""border-bottom: 1px solid #e2e8f0;"">
|
||||
<td style=""padding: 10px 14px; font-weight: 600; color: #d97706; font-size: 12px;"">Milestone SLA</td>
|
||||
<td style=""padding: 10px 14px; font-weight: 500; color: #1e293b;"">{mName}</td>
|
||||
<td style=""padding: 10px 14px; color: #dc2626; font-size: 13px;"">Due: {m.DueAt:yyyy-MM-dd} (+{delay} d)</td>
|
||||
<td style=""padding: 10px 14px; font-size: 12px;""><span style=""background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-weight: 600;"">Overdue</span></td>
|
||||
</tr>");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public static class Constants
|
||||
public const string DatabaseConfigName = "PfmDatabase";
|
||||
public const string DefaultSchedulerName = "pfm-scheduler";
|
||||
public const string SchedulerDatabaseConfigName = "PfmSchedulerDatabase";
|
||||
|
||||
|
||||
public const int LabelLength = 256;
|
||||
public const int ShortLabelLength = 100;
|
||||
public const int TextLength = 1024;
|
||||
@@ -29,6 +29,7 @@ public static class Constants
|
||||
|
||||
public const string FeatureEmailProcessor = "EmailProcessorJobEnabled";
|
||||
public const string FeatureLateProjectReminder = "LateProjectReminderEnabled";
|
||||
public const string FeatureNonCompliantProjectCheck = "NonCompliantProjectCheckEnabled";
|
||||
public const string FeatureServiceCaching = "ServiceCaching";
|
||||
|
||||
public static Guid SystemUser { get; } = new(0x6059f1e4, 0x6a41, 0x4447, 0xa5, 0x46, 0x86, 0xec, 0xe5, 0x10, 0x20, 0x4) /* 6059f1e4-6a41-4447-a546-86ece5102004 */;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<ItemGroup>
|
||||
<None Remove="Templates\lateprojectsmilestones.html" />
|
||||
<None Remove="Templates\noncompliantprojects.html" />
|
||||
<None Remove="Templates\noncompliantprojects-prog.html" />
|
||||
<EmbeddedResource Include="Templates\lateprojectsmilestones.html" />
|
||||
<EmbeddedResource Include="Templates\noncompliantprojects.html" />
|
||||
<EmbeddedResource Include="Templates\noncompliantprojects-prog.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Transformation -->
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Project Milestone Overdue Notification</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
|
||||
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Main Container Card (600px) -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||
|
||||
<!-- Header Bar -->
|
||||
<tr>
|
||||
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #e11d48;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
|
||||
Post-Fund Management Platform (PFM)
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
|
||||
Portfolio Performance & Monitoring
|
||||
</div>
|
||||
</td>
|
||||
<td align="right" style="vertical-align: middle;">
|
||||
<span style="display: inline-block; background-color: rgba(225, 29, 72, 0.2); border: 1px solid rgba(225, 29, 72, 0.5); color: #fda4af; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Action Required
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alert Banner -->
|
||||
<tr>
|
||||
<td style="background-color: #fff1f2; border-bottom: 1px solid #ffe4e6; padding: 16px 32px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
|
||||
<div style="width: 24px; height: 24px; background-color: #e11d48; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
|
||||
!
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-size: 14px; font-weight: 700; color: #9f1239; margin-bottom: 2px;">
|
||||
Overdue Milestone Notice: [TotalLateMilestones] Milestone(s) Exceeded Due Date
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #be123c;">
|
||||
Automated daily sweep flagged scheduled deliverables requiring evidence submission or status updates.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content Body -->
|
||||
<tr>
|
||||
<td style="padding: 32px;">
|
||||
|
||||
<!-- Salutation -->
|
||||
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
|
||||
Dear <strong>[OrganisationName]</strong> Team,
|
||||
</p>
|
||||
|
||||
<!-- Message -->
|
||||
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
|
||||
During our automated lifecycle monitoring sweep, our platform identified one or more contractual milestones for
|
||||
<strong style="color: #0f172a;">[ProjectName]</strong> that have passed their agreed target completion deadlines without formal evidence approval.
|
||||
</p>
|
||||
|
||||
<!-- Project Overview Box -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
|
||||
<tr>
|
||||
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Project Summary
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Project Title</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Implementation Window</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] — [ProjectEndDate]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Overdue Deliverables</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 700; color: #e11d48; border-bottom: 1px solid #f1f5f9;">
|
||||
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px;">[TotalLateMilestones] Milestone(s)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Most Overdue Milestone</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #334155;">
|
||||
<strong>[MostLateMilestoneName]</strong> <span style="color: #64748b; font-size: 12px;">(Due: [MostLateMilestoneDueDate])</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Detailed Milestones Table Header -->
|
||||
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Milestone Breakdown
|
||||
</div>
|
||||
|
||||
<!-- Milestones Table -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
|
||||
<thead>
|
||||
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Milestone</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Due Date</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Delay</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
[LateMilestonesTable]
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Governance & SLA Callout -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-left: 4px solid #0284c7; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 12px; font-weight: 700; color: #0369a1; text-transform: uppercase; margin-bottom: 4px;">
|
||||
Disbursement & Governance Policy (BR-003 / BR-004)
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #475569; line-height: 1.5;">
|
||||
Under PFM governance regulations, tranches and disbursements are strictly gated by verified milestone deliverables. Overdue milestones may trigger project risk escalations and freeze scheduled payment releases.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 28px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="[PortalUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
|
||||
Open PFM Portal & Submit Evidence →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.5;">
|
||||
If you have already submitted the evidence for these deliverables or have an active change request under review with your Programme Manager, you may disregard this notice.
|
||||
</p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
|
||||
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
|
||||
Post-Fund Management (PFM) Platform • Automated Lifecycle & SLA Daemon
|
||||
</p>
|
||||
<p style="margin: 0 0 12px 0; line-height: 1.4;">
|
||||
This is an automated contractual notification sent in accordance with your funding agreement SLA.<br>
|
||||
Please do not reply directly to this email. For support, please contact your designated Programme Officer.
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
|
||||
© [Year] PostFundManagement Platform. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Programme Oversight: Project Milestone Escalation Notice</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
|
||||
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; padding: 32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Main Container Card (600px) -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||
|
||||
<!-- Header Bar -->
|
||||
<tr>
|
||||
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #d97706;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
|
||||
PFM Internal Governance • FR-M07-009 Escalation
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
|
||||
Programme Milestone Escalation
|
||||
</div>
|
||||
</td>
|
||||
<td align="right" style="vertical-align: middle;">
|
||||
<span style="display: inline-block; background-color: rgba(217, 119, 6, 0.2); border: 1px solid rgba(217, 119, 6, 0.6); color: #fde68a; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Internal Notice
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alert Banner -->
|
||||
<tr>
|
||||
<td style="background-color: #fffbeb; border-bottom: 1px solid #fef3c7; padding: 16px 32px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
|
||||
<div style="width: 24px; height: 24px; background-color: #d97706; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
|
||||
!
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-size: 14px; font-weight: 700; color: #92400e; margin-bottom: 2px;">
|
||||
Milestone SLA Breach: [TotalLateMilestones] Deliverable(s) Past Due Date
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #b45309;">
|
||||
Project under your programme governance has breached milestone delivery deadlines. Automated partner reminder was dispatched.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content Body -->
|
||||
<tr>
|
||||
<td style="padding: 32px;">
|
||||
|
||||
<!-- Salutation -->
|
||||
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
|
||||
Dear <strong>[ProgrammeManagerName]</strong>,
|
||||
</p>
|
||||
|
||||
<!-- Context Narrative -->
|
||||
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
|
||||
In accordance with portfolio monitoring requirement <strong>FR-M07-009</strong>, this automated escalation notice alerts you that
|
||||
<strong style="color: #0f172a;">[ProjectName]</strong> has overdue milestones requiring your oversight and possible intervention.
|
||||
</p>
|
||||
|
||||
<!-- Governance Context Box -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
|
||||
<tr>
|
||||
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Governance & Project Hierarchy
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Programme</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProgrammeName]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Portfolio</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[PortfolioName]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Recipient Organisation</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[OrganisationName]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Project Title</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Project Duration</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] — [ProjectEndDate]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Overdue Status</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px;">
|
||||
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-weight: 700;">
|
||||
[TotalLateMilestones] Milestone(s) Overdue
|
||||
</span>
|
||||
<span style="color: #64748b; font-size: 12px; margin-left: 6px;">(Primary: [MostLateMilestoneName], [MostLateMilestoneDaysOverdue] days late)</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Detailed Milestones Table Header -->
|
||||
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Delinquent Deliverables Breakdown
|
||||
</div>
|
||||
|
||||
<!-- Milestones Table -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
|
||||
<thead>
|
||||
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Milestone</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Due Date</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Delay</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
[LateMilestonesTable]
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Recommended Action Guidance -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-left: 4px solid #d97706; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 12px; font-weight: 700; color: #92400e; text-transform: uppercase; margin-bottom: 4px;">
|
||||
Recommended Manager Actions
|
||||
</div>
|
||||
<ul style="margin: 0; padding-left: 18px; font-size: 12px; color: #475569; line-height: 1.6;">
|
||||
<li>Verify whether unreviewed evidence submissions are awaiting your sign-off in the Admin Console.</li>
|
||||
<li>Engage recipient organisation leadership regarding timeline remediation or change requests (M15).</li>
|
||||
<li>Ensure disbursements linked to gated milestones remain withheld per rule BR-003.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 24px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="[ConsoleUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
|
||||
Open Programme in Console →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
|
||||
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
|
||||
Post-Fund Management (PFM) Platform • Automated Governance & Monitoring Daemon
|
||||
</p>
|
||||
<p style="margin: 0 0 12px 0; line-height: 1.4;">
|
||||
You received this notification because you are designated as the Programme Owner/Manager for [ProgrammeName].<br>
|
||||
Automated escalation according to SRS v2.0 requirement FR-M07-009.
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
|
||||
© [Year] PostFundManagement Platform. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>Weekly Governance Audit: Project Non-Compliance Notice</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td { font-family: Arial, Helvetica, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #1e293b; line-height: 1.6;">
|
||||
|
||||
<!-- Wrapper Table -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Main Container Card (600px) -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||
|
||||
<!-- Header Bar -->
|
||||
<tr>
|
||||
<td style="background-color: #0f172a; padding: 24px 32px; border-bottom: 3px solid #be123c;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 4px;">
|
||||
Post-Fund Management Platform (PFM) • M10 Compliance
|
||||
</div>
|
||||
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.3px;">
|
||||
Weekly Project Compliance Audit
|
||||
</div>
|
||||
</td>
|
||||
<td align="right" style="vertical-align: middle;">
|
||||
<span style="display: inline-block; background-color: rgba(190, 18, 60, 0.2); border: 1px solid rgba(190, 18, 60, 0.6); color: #fda4af; padding: 4px 10px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Non-Compliant
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Alert Banner -->
|
||||
<tr>
|
||||
<td style="background-color: #fff1f2; border-bottom: 1px solid #ffe4e6; padding: 16px 32px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="width: 28px; vertical-align: top; padding-right: 12px;">
|
||||
<div style="width: 24px; height: 24px; background-color: #be123c; border-radius: 50%; color: #ffffff; text-align: center; line-height: 24px; font-size: 13px; font-weight: bold;">
|
||||
!
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-size: 14px; font-weight: 700; color: #9f1239; margin-bottom: 2px;">
|
||||
Action Required: Project Flagged as Non-Compliant ([ComplianceScore] Met)
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #be123c;">
|
||||
Weekly automated governance audit identified unmet statutory obligations and/or overdue milestone deliverables.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content Body -->
|
||||
<tr>
|
||||
<td style="padding: 32px;">
|
||||
|
||||
<!-- Salutation -->
|
||||
<p style="margin: 0 0 16px 0; font-size: 15px; color: #334155;">
|
||||
Dear <strong>[OrganisationName]</strong> Team,
|
||||
</p>
|
||||
|
||||
<!-- Narrative -->
|
||||
<p style="margin: 0 0 24px 0; font-size: 14px; color: #475569; line-height: 1.6;">
|
||||
During our weekly automated compliance audit, <strong style="color: #0f172a;">[ProjectName]</strong> (under programme <strong>[ProgrammeName]</strong>) did not satisfy the mandatory statutory and contractual compliance threshold. In accordance with system governance policies, immediate remediation is required.
|
||||
</p>
|
||||
|
||||
<!-- Project Summary Box -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; margin-bottom: 24px; overflow: hidden;">
|
||||
<tr>
|
||||
<td colspan="2" style="background-color: #f1f5f9; padding: 10px 16px; border-bottom: 1px solid #e2e8f0; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Audit Findings Overview
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; width: 40%; border-bottom: 1px solid #f1f5f9;">Project Title</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">[ProjectName]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Programme / Portfolio</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProgrammeName] • [PortfolioName]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Implementation Window</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #334155; border-bottom: 1px solid #f1f5f9;">[ProjectStartDate] — [ProjectEndDate]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Compliance Verdict</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #f1f5f9;">
|
||||
<span style="background-color: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 4px; font-weight: 700;">
|
||||
Non-Compliant — Score: [ComplianceScore]
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b; border-bottom: 1px solid #f1f5f9;">Unmet Compliance Items</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #be123c; border-bottom: 1px solid #f1f5f9;">
|
||||
[UnmetComplianceCount] Item(s) Pending Resolution
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; font-size: 13px; color: #64748b;">Overdue Milestones</td>
|
||||
<td style="padding: 12px 16px; font-size: 13px; font-weight: 600; color: #be123c;">
|
||||
[UnmetMilestonesCount] Milestone(s) Overdue
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Items Table Header -->
|
||||
<div style="font-size: 13px; font-weight: 700; color: #1e293b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
Outstanding Compliance & Deliverable Items
|
||||
</div>
|
||||
|
||||
<!-- Non-Compliance Items Table -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-bottom: 24px; font-size: 13px; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;">
|
||||
<thead>
|
||||
<tr style="background-color: #f8fafc; text-align: left; border-bottom: 2px solid #e2e8f0;">
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Category</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Item / Obligation</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Details / Due Date</th>
|
||||
<th style="padding: 10px 14px; font-size: 11px; font-weight: 700; color: #475569; text-transform: uppercase;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
[NonComplianceTable]
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Governance & Disbursement Hold Callout -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #fff1f2; border-left: 4px solid #be123c; padding: 14px 18px; border-radius: 4px; margin-bottom: 28px;">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 12px; font-weight: 700; color: #9f1239; text-transform: uppercase; margin-bottom: 4px;">
|
||||
Disbursement Hold Notice (BR-003 / AT-08)
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #475569; line-height: 1.5;">
|
||||
Under cardinal rule BR-003, all pending tranche disbursements and drawdown requests are frozen while a project carries non-compliant obligations. Please upload the required compliance verification evidence through the Partner Portal to restore good standing.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 28px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="[PortalUrl]" target="_blank" style="display: inline-block; background-color: #0f172a; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 32px; border-radius: 6px; box-shadow: 0 2px 4px rgba(15, 23, 42, 0.15);">
|
||||
Open Partner Portal & Submit Compliance Evidence →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.5;">
|
||||
Once evidence is submitted, your designated Programme Officer will review and verify the documentation to restore your project's 100% compliant status.
|
||||
</p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f8fafc; padding: 24px 32px; border-top: 1px solid #e2e8f0; font-size: 12px; color: #94a3b8; text-align: center;">
|
||||
<p style="margin: 0 0 8px 0; color: #64748b; font-weight: 600;">
|
||||
Post-Fund Management (PFM) Platform • Weekly Compliance Audit Daemon
|
||||
</p>
|
||||
<p style="margin: 0 0 12px 0; line-height: 1.4;">
|
||||
Automated statutory oversight notification generated in accordance with SRS v2.0 Module M10.<br>
|
||||
Please do not reply directly to this email. For assistance, contact your Programme Officer.
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 11px; color: #cbd5e1;">
|
||||
© [Year] PostFundManagement Platform. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@ public class JobsWorker(IJobOrchestrator jobOrchestrator, IFeatureManager featur
|
||||
await jobOrchestrator.ScheduleAsync(ProcessEmailEvent.Create(), "0 */5 * * * ?", stoppingToken);
|
||||
|
||||
if (await featureManager.IsEnabledAsync(FeatureLateProjectReminder))
|
||||
await jobOrchestrator.ScheduleAsync(LateProjectEvent.Create(), "", stoppingToken);
|
||||
|
||||
// TODO: Add job to check for non-compliant projects once a week
|
||||
await jobOrchestrator.ScheduleAsync(LateProjectEvent.Create(), "0 30 6 * * ?", stoppingToken);
|
||||
|
||||
if (await featureManager.IsEnabledAsync(FeatureNonCompliantProjectCheck))
|
||||
await jobOrchestrator.ScheduleAsync(NonCompliantProjectEvent.Create(), "0 0 7 ? * MON", stoppingToken);
|
||||
|
||||
logger.LogInformation("Recurring jobs have been registered or updated.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user