using Microsoft.Extensions.Options;
using OnePlanWeeklyNotifications.Models; using OnePlanWeeklyNotifications.Options; using OnePlanWeeklyNotifications.Services; namespace OnePlanWeeklyNotifications { /// <summary> /// Background service that sends weekly OnePlan approval reminders. /// Collects Project Approvers tasks whose deliverable status is "Submitted", /// resolves the assigned resources, and emails each resource a summary of pending items. /// Work type and status IDs are configured in appsettings.json (OnePlan section). /// </summary> public class Worker( ILogger<Worker> logger, IOnePlanClient onePlan, IEmailSender emailSender, IOptions<NotificationOptions> notificationOptions, IOptions<OnePlanOptions> onePlanOptions) : BackgroundService { // Weekly schedule and email subject settings (Notification section). private readonly NotificationOptions _schedule = notificationOptions.Value; // OnePlan API and filter settings (OnePlan section). private readonly OnePlanOptions _onePlan = onePlanOptions.Value; // Work type IDs that must never be notified (e.g. Backlog), loaded from configuration. private readonly HashSet<string> _excludedWorkTypeIds = new(onePlanOptions.Value.ExcludedWorkTypeIds, StringComparer.OrdinalIgnoreCase); /// <summary> /// Main loop: waits until the configured day/time, then runs the notification job. /// Currently runs once immediately (weekly delay is commented out for testing). /// </summary> protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { //var delay = GetDelayUntilNextRun(DateTime.Now); //logger.LogInformation("Next approval notification run at {NextRun}.", DateTime.Now.Add(delay)); //await Task.Delay(delay, stoppingToken); try { await RunAsync(stoppingToken); // await emailSender.SendAsync("Mohammad.Chowdhury@PACCAR.com", "Test", "Test body", stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { // Log and continue so one failed run doesn't stop the service. logger.LogError(ex, "Weekly approval notification run failed."); } // TODO: remove when re-enabling the weekly delay above break; } } /// <summary> /// Collects pending deliverables, groups them by recipient email, /// and sends one HTML summary email per recipient. Failures are logged per recipient. /// </summary> private async Task RunAsync(CancellationToken ct) { var pending = await CollectPendingDeliverablesAsync(ct); logger.LogInformation("Found {Count} pending deliverable assignment(s).", pending.Count); // One email per recipient, containing all of their pending items. foreach (var group in pending.GroupBy(p => p.Resource.Fields.Mail!, StringComparer.OrdinalIgnoreCase)) { var html = ApprovalEmailBuilder.Build(group.First().Resource, group, _onePlan.BaseUrl); try { await emailSender.SendAsync(group.Key, _schedule.Subject, html, ct); logger.LogInformation("Sent {Count} item(s) to {Email}.", group.Count(), group.Key); } catch (Exception ex) when (ex is not OperationCanceledException) { // A failure for one recipient does not block the others. logger.LogError(ex, "Failed to email {Email}.", group.Key); } } } /// <summary> /// Loads all work plans and their tasks, keeps only Project Approvers tasks that are /// pending approval and not in the excluded work types, then resolves each assigned /// resource (cached per run) and returns those with a valid email address. /// </summary> private async Task<List<PendingDeliverable>> CollectPendingDeliverablesAsync(CancellationToken ct) { var results = new List<PendingDeliverable>(); // Cache resource lookups to avoid repeated API calls for the same person. var resourceCache = new Dictionary<string, Resource?>(StringComparer.OrdinalIgnoreCase); foreach (var plan in await onePlan.GetWorkPlansAsync(ct)) { //if (plan.Id == "dd1f1d7c-161b-4dfe-9535-3ff894cd358b") //{ List<WorkPlanTask> tasks; try { tasks = await onePlan.GetTasksAsync(plan.Id, ct); } catch (HttpRequestException ex) { // Skip this plan if its tasks can't be loaded. logger.LogWarning(ex, "Failed to load tasks for plan {PlanId}.", plan.Id); continue; } // Filter: not excluded work type, is Project Approvers, and status is pending approval. var filtered = tasks.Where(t => !_excludedWorkTypeIds.Contains(t.WorkTypeId ?? string.Empty) && string.Equals(t.WorkTypeId, _onePlan.ProjectApproversWorkTypeId, StringComparison.OrdinalIgnoreCase) && string.Equals(t.Fields.DeliverableStatus, _onePlan.PendingApprovalStatusId, StringComparison.OrdinalIgnoreCase)); foreach (var task in filtered) { foreach (var assignment in task.Assignments) { if (string.IsNullOrWhiteSpace(assignment.ResourceId)) continue; // Look up the assigned resource once per run; failed lookups are cached as null. if (!resourceCache.TryGetValue(assignment.ResourceId, out var resource)) { try { resource = await onePlan.GetResourceAsync(assignment.ResourceId, ct); } catch (HttpRequestException ex) { logger.LogWarning(ex, "Failed to load resource {ResourceId}.", assignment.ResourceId); resource = null; } resourceCache[assignment.ResourceId] = resource; } // Only include resources that have an email address. if (resource is null || string.IsNullOrWhiteSpace(resource.Fields.Mail)) continue; results.Add(new PendingDeliverable(plan, task, resource)); } } } // } return results; } /// <summary> /// Calculates the time remaining until the next configured weekly run (DayOfWeek + TimeOfDay). /// If that time has already passed this week, schedules for next week. /// </summary> private TimeSpan GetDelayUntilNextRun(DateTime now) { var daysUntil = ((int)_schedule.DayOfWeek - (int)now.DayOfWeek + 7) % 7; var next = now.Date.AddDays(daysUntil).Add(_schedule.TimeOfDay); if (next <= now) next = next.AddDays(7); return next - now; } } }
No comments:
Post a Comment