using Cronos; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Collections.Concurrent; namespace StellaOps.Notifier.Worker.Digest; /// /// Manages scheduled digest configurations. /// public interface IDigestScheduler { /// /// Gets all scheduled digests for a tenant. /// Task> GetSchedulesAsync( string tenantId, CancellationToken cancellationToken = default); /// /// Gets a specific schedule by ID. /// Task GetScheduleAsync( string tenantId, string scheduleId, CancellationToken cancellationToken = default); /// /// Creates or updates a digest schedule. /// Task UpsertScheduleAsync( DigestSchedule schedule, CancellationToken cancellationToken = default); /// /// Deletes a digest schedule. /// Task DeleteScheduleAsync( string tenantId, string scheduleId, CancellationToken cancellationToken = default); /// /// Gets schedules due for execution. /// Task> GetDueSchedulesAsync( DateTimeOffset asOf, CancellationToken cancellationToken = default); /// /// Updates the last run time for a schedule. /// Task UpdateLastRunAsync( string tenantId, string scheduleId, DateTimeOffset runTime, CancellationToken cancellationToken = default); } /// /// Digest schedule configuration. /// public sealed record DigestSchedule { /// /// Unique schedule ID. /// public required string ScheduleId { get; init; } /// /// Tenant ID. /// public required string TenantId { get; init; } /// /// Schedule name. /// public required string Name { get; init; } /// /// Whether the schedule is enabled. /// public required bool Enabled { get; init; } /// /// Cron expression for schedule timing (5-field or 6-field). /// public required string CronExpression { get; init; } /// /// Timezone for cron evaluation (IANA format). /// public string? Timezone { get; init; } /// /// Digest type to generate. /// public DigestType DigestType { get; init; } = DigestType.Daily; /// /// Period lookback for the digest (e.g., 24 hours for daily). /// public TimeSpan? LookbackPeriod { get; init; } /// /// Output format. /// public DigestFormat Format { get; init; } = DigestFormat.Html; /// /// Event kind filters (null = all). /// public IReadOnlyList? EventKindFilter { get; init; } /// /// Whether to include resolved incidents. /// public bool IncludeResolved { get; init; } = false; /// /// Recipients for the digest. /// public IReadOnlyList? Recipients { get; init; } /// /// Last successful run time. /// public DateTimeOffset? LastRunAt { get; set; } /// /// Next scheduled run time. /// public DateTimeOffset? NextRunAt { get; set; } /// /// When the schedule was created. /// public DateTimeOffset CreatedAt { get; init; } /// /// Who created the schedule. /// public string? CreatedBy { get; init; } } /// /// Digest recipient configuration. /// public sealed record DigestRecipient { /// /// Recipient type (email, webhook, channel). /// public required string Type { get; init; } /// /// Recipient address (email, URL, channel ID). /// public required string Address { get; init; } /// /// Optional display name. /// public string? Name { get; init; } } /// /// In-memory implementation of . /// public sealed class InMemoryDigestScheduler : IDigestScheduler { private readonly ConcurrentDictionary _schedules = new(); private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public InMemoryDigestScheduler( TimeProvider timeProvider, ILogger logger) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public Task> GetSchedulesAsync( string tenantId, CancellationToken cancellationToken = default) { var schedules = _schedules.Values .Where(s => s.TenantId == tenantId) .OrderBy(s => s.Name) .ToList(); return Task.FromResult>(schedules); } public Task GetScheduleAsync( string tenantId, string scheduleId, CancellationToken cancellationToken = default) { var key = BuildKey(tenantId, scheduleId); _schedules.TryGetValue(key, out var schedule); return Task.FromResult(schedule); } public Task UpsertScheduleAsync( DigestSchedule schedule, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(schedule); // Calculate next run time var updatedSchedule = schedule with { NextRunAt = CalculateNextRun(schedule) }; var key = BuildKey(schedule.TenantId, schedule.ScheduleId); _schedules[key] = updatedSchedule; _logger.LogInformation( "Upserted digest schedule {ScheduleId} for tenant {TenantId}, next run at {NextRun}.", schedule.ScheduleId, schedule.TenantId, updatedSchedule.NextRunAt); return Task.FromResult(updatedSchedule); } public Task DeleteScheduleAsync( string tenantId, string scheduleId, CancellationToken cancellationToken = default) { var key = BuildKey(tenantId, scheduleId); var removed = _schedules.TryRemove(key, out _); if (removed) { _logger.LogInformation( "Deleted digest schedule {ScheduleId} for tenant {TenantId}.", scheduleId, tenantId); } return Task.FromResult(removed); } public Task> GetDueSchedulesAsync( DateTimeOffset asOf, CancellationToken cancellationToken = default) { var dueSchedules = _schedules.Values .Where(s => s.Enabled) .Where(s => s.NextRunAt.HasValue && s.NextRunAt.Value <= asOf) .ToList(); return Task.FromResult>(dueSchedules); } public Task UpdateLastRunAsync( string tenantId, string scheduleId, DateTimeOffset runTime, CancellationToken cancellationToken = default) { var key = BuildKey(tenantId, scheduleId); if (_schedules.TryGetValue(key, out var schedule)) { var updatedSchedule = schedule with { LastRunAt = runTime, NextRunAt = CalculateNextRun(schedule, runTime) }; _schedules[key] = updatedSchedule; _logger.LogDebug( "Updated last run for schedule {ScheduleId}, next run at {NextRun}.", scheduleId, updatedSchedule.NextRunAt); } return Task.CompletedTask; } private DateTimeOffset? CalculateNextRun(DigestSchedule schedule, DateTimeOffset? from = null) { if (!schedule.Enabled) { return null; } try { var expression = CronExpression.Parse(schedule.CronExpression, CronFormat.IncludeSeconds); var fromTime = from ?? _timeProvider.GetUtcNow(); TimeZoneInfo tz = TimeZoneInfo.Utc; if (!string.IsNullOrWhiteSpace(schedule.Timezone)) { try { tz = TimeZoneInfo.FindSystemTimeZoneById(schedule.Timezone); } catch { // Invalid timezone, use UTC } } var next = expression.GetNextOccurrence(fromTime.UtcDateTime, tz); return next.HasValue ? new DateTimeOffset(next.Value, TimeSpan.Zero) : null; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse cron expression '{Cron}' for schedule {ScheduleId}.", schedule.CronExpression, schedule.ScheduleId); return null; } } private static string BuildKey(string tenantId, string scheduleId) => $"{tenantId}:{scheduleId}"; } /// /// Configuration options for digest scheduling. /// public sealed class DigestSchedulerOptions { /// /// Configuration section name. /// public const string SectionName = "Notifier:DigestScheduler"; /// /// How often to check for due schedules. /// public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(1); /// /// Maximum concurrent digest generations. /// public int MaxConcurrent { get; set; } = 5; /// /// Default lookback periods by digest type. /// public Dictionary DefaultLookbacks { get; set; } = new() { [DigestType.Daily] = TimeSpan.FromHours(24), [DigestType.Weekly] = TimeSpan.FromDays(7), [DigestType.Monthly] = TimeSpan.FromDays(30) }; }