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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user