Tuesday, November 10, 2026

OnePlan Automation

 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

OnePlan Automation

  using Microsoft.Extensions.Options; using OnePlanWeeklyNotifications.Models; using OnePlanWeeklyNotifications.Options; using OnePlanWe...