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)
};
}