diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Storage/Compat/QuietHoursMaintenancePostgresCompat.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Storage/Compat/QuietHoursMaintenancePostgresCompat.cs new file mode 100644 index 000000000..7e4adcf43 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Storage/Compat/QuietHoursMaintenancePostgresCompat.cs @@ -0,0 +1,303 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Notify.Models; +using StellaOps.Notify.Persistence.Postgres.Models; +using StellaOps.Notify.Persistence.Postgres.Repositories; +using StellaOps.Notifier.Worker.Correlation; + +namespace StellaOps.Notifier.WebService.Storage.Compat; + +public sealed class PostgresNotifyQuietHoursRepository : INotifyQuietHoursRepository +{ + private readonly IServiceScopeFactory _scopeFactory; + + public PostgresNotifyQuietHoursRepository(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + } + + public async Task> ListAsync( + string tenantId, + string? channelId, + bool? enabledOnly, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + IEnumerable schedules = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Select(MapCompatSchedule) + .Where(schedule => schedule is not null) + .Select(schedule => schedule!) + .OrderBy(schedule => schedule.Name, StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(channelId)) + { + schedules = schedules.Where(schedule => + string.Equals(schedule.ChannelId, channelId, StringComparison.OrdinalIgnoreCase)); + } + + if (enabledOnly is true) + { + schedules = schedules.Where(schedule => schedule.Enabled); + } + + return schedules.ToList(); + } + + public async Task GetAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + scheduleId, + StringComparison.Ordinal)) + .Select(MapCompatSchedule) + .FirstOrDefault(schedule => schedule is not null); + } + + public async Task UpsertAsync( + NotifyQuietHoursSchedule schedule, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(schedule); + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existingRows = (await repository.ListAsync(schedule.TenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + schedule.ScheduleId, + StringComparison.Ordinal)) + .ToList(); + + foreach (var row in existingRows) + { + await repository.DeleteAsync(schedule.TenantId, row.Id, cancellationToken).ConfigureAwait(false); + } + + var projection = QuietHoursRuntimeProjection.TryProjectCompatSchedule(schedule, out var projectedSchedule) + ? projectedSchedule + : QuietHoursRuntimeProjection.FallbackProjection(schedule.Name, schedule.TimeZone, schedule.Enabled); + var existing = existingRows.FirstOrDefault(); + var entity = new QuietHoursEntity + { + Id = existing?.Id ?? Guid.NewGuid(), + TenantId = schedule.TenantId, + UserId = null, + ChannelId = Guid.TryParse(schedule.ChannelId, out var channelGuid) ? channelGuid : null, + CalendarId = schedule.ScheduleId, + StartTime = TimeOnly.Parse(projectedSchedule.StartTime), + EndTime = TimeOnly.Parse(projectedSchedule.EndTime), + Timezone = projectedSchedule.Timezone ?? "UTC", + DaysOfWeek = projectedSchedule.DaysOfWeek?.ToArray() ?? [0, 1, 2, 3, 4, 5, 6], + Enabled = schedule.Enabled, + Metadata = QuietHoursRuntimeProjection.SerializeQuietHoursMetadata( + new PersistedQuietHoursMetadata( + schedule.Name, + schedule.Description, + 100, + schedule.Name, + null, + null, + schedule.CronExpression, + schedule.Duration.TotalSeconds, + schedule.ChannelId, + schedule.Description, + schedule.Metadata.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal))), + CreatedAt = existing?.CreatedAt ?? schedule.CreatedAt, + CreatedBy = existing?.CreatedBy ?? schedule.CreatedBy, + UpdatedAt = schedule.UpdatedAt, + UpdatedBy = schedule.UpdatedBy ?? schedule.CreatedBy, + }; + + await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); + return MapCompatSchedule(entity)!; + } + + public async Task DeleteAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var rows = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + scheduleId, + StringComparison.Ordinal)) + .ToList(); + + var deleted = false; + foreach (var row in rows) + { + deleted |= await repository.DeleteAsync(tenantId, row.Id, cancellationToken).ConfigureAwait(false); + } + + return deleted; + } + + private static NotifyQuietHoursSchedule? MapCompatSchedule(QuietHoursEntity row) + { + var metadata = QuietHoursRuntimeProjection.DeserializeQuietHoursMetadata(row.Metadata); + if (string.IsNullOrWhiteSpace(metadata.CompatCronExpression)) + { + return null; + } + + return NotifyQuietHoursSchedule.Create( + scheduleId: QuietHoursRuntimeProjection.ResolveCalendarId(row), + tenantId: row.TenantId, + name: metadata.CalendarName + ?? metadata.ScheduleName + ?? QuietHoursRuntimeProjection.ResolveCalendarId(row), + cronExpression: metadata.CompatCronExpression, + duration: TimeSpan.FromSeconds(metadata.CompatDurationSeconds ?? 0), + timeZone: row.Timezone, + channelId: metadata.CompatChannelId, + enabled: row.Enabled, + description: metadata.CompatDescription, + metadata: metadata.CompatMetadata, + createdBy: row.CreatedBy, + createdAt: row.CreatedAt, + updatedBy: row.UpdatedBy, + updatedAt: row.UpdatedAt); + } +} + +public sealed class PostgresNotifyMaintenanceWindowRepository : INotifyMaintenanceWindowRepository +{ + private readonly IServiceScopeFactory _scopeFactory; + + public PostgresNotifyMaintenanceWindowRepository(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + } + + public async Task> ListAsync( + string tenantId, + bool? activeOnly, + DateTimeOffset now, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + IEnumerable windows = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Select(MapWindow) + .Where(window => window is not null) + .Select(window => window!) + .OrderBy(window => window.StartsAt) + .ThenBy(window => window.WindowId, StringComparer.Ordinal); + + if (activeOnly is true) + { + windows = windows.Where(window => window.IsActiveAt(now)); + } + + return windows.ToList(); + } + + public async Task GetAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals(ResolveWindowId(row), windowId, StringComparison.Ordinal)) + .Select(MapWindow) + .FirstOrDefault(window => window is not null); + } + + public async Task UpsertAsync( + NotifyMaintenanceWindow window, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(window); + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existing = (await repository.ListAsync(window.TenantId, cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(candidate => string.Equals(ResolveWindowId(candidate), window.WindowId, StringComparison.Ordinal)); + + var entity = new MaintenanceWindowEntity + { + Id = existing?.Id ?? Guid.NewGuid(), + TenantId = window.TenantId, + ExternalId = window.WindowId, + Name = window.Name, + Description = window.Reason, + StartAt = window.StartsAt, + EndAt = window.EndsAt, + SuppressNotifications = window.SuppressNotifications, + SuppressChannels = window.ChannelIds + .Select(value => Guid.TryParse(value, out var parsed) ? parsed : Guid.Empty) + .Where(value => value != Guid.Empty) + .ToArray(), + SuppressEventTypes = null, + Metadata = QuietHoursRuntimeProjection.SerializeMaintenanceWindowMetadata( + new PersistedMaintenanceWindowMetadata( + window.ChannelIds.ToList(), + window.RuleIds.ToList(), + window.Metadata.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal))), + CreatedAt = existing?.CreatedAt ?? window.CreatedAt, + CreatedBy = existing?.CreatedBy ?? window.CreatedBy, + UpdatedAt = window.UpdatedAt, + UpdatedBy = window.UpdatedBy ?? window.CreatedBy, + }; + + if (existing is null) + { + await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); + } + else + { + await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false); + } + + return MapWindow(entity)!; + } + + public async Task DeleteAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existing = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(candidate => string.Equals(ResolveWindowId(candidate), windowId, StringComparison.Ordinal)); + + return existing is not null && + await repository.DeleteAsync(tenantId, existing.Id, cancellationToken).ConfigureAwait(false); + } + + private static NotifyMaintenanceWindow? MapWindow(MaintenanceWindowEntity entity) + { + var metadata = QuietHoursRuntimeProjection.DeserializeMaintenanceWindowMetadata(entity.Metadata); + return NotifyMaintenanceWindow.Create( + windowId: ResolveWindowId(entity), + tenantId: entity.TenantId, + name: entity.Name, + startsAt: entity.StartAt, + endsAt: entity.EndAt, + suppressNotifications: entity.SuppressNotifications, + reason: entity.Description, + channelIds: metadata.ChannelIds, + ruleIds: metadata.RuleIds, + metadata: metadata.Metadata, + createdBy: entity.CreatedBy, + createdAt: entity.CreatedAt, + updatedBy: entity.UpdatedBy, + updatedAt: entity.UpdatedAt); + } + + private static string ResolveWindowId(MaintenanceWindowEntity entity) + => string.IsNullOrWhiteSpace(entity.ExternalId) ? entity.Id.ToString("N") : entity.ExternalId; +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Correlation/PostgresQuietHoursRuntimeServices.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Correlation/PostgresQuietHoursRuntimeServices.cs new file mode 100644 index 000000000..98c1f1899 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Correlation/PostgresQuietHoursRuntimeServices.cs @@ -0,0 +1,667 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Notify.Persistence.Postgres.Models; +using StellaOps.Notify.Persistence.Postgres.Repositories; +using WorkerAuditRepository = StellaOps.Notifier.Worker.Storage.INotifyAuditRepository; + +namespace StellaOps.Notifier.Worker.Correlation; + +public sealed class PostgresQuietHoursCalendarService : IQuietHoursCalendarService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public PostgresQuietHoursCalendarService( + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ListCalendarsAsync( + string tenantId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var rows = await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + + return rows + .GroupBy(QuietHoursRuntimeProjection.ResolveCalendarId, StringComparer.Ordinal) + .Select(MapCalendar) + .OrderBy(calendar => calendar.Priority) + .ThenBy(calendar => calendar.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task GetCalendarAsync( + string tenantId, + string calendarId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var rows = await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + + return rows + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + calendarId, + StringComparison.Ordinal)) + .GroupBy(QuietHoursRuntimeProjection.ResolveCalendarId, StringComparer.Ordinal) + .Select(MapCalendar) + .FirstOrDefault(); + } + + public async Task UpsertCalendarAsync( + QuietHoursCalendar calendar, + string? actor, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(calendar); + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var auditRepository = scope.ServiceProvider.GetService(); + var existingRows = await repository.ListAsync(calendar.TenantId, cancellationToken).ConfigureAwait(false); + var calendarRows = existingRows + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + calendar.CalendarId, + StringComparison.Ordinal)) + .ToList(); + + var now = _timeProvider.GetUtcNow(); + var createdAt = calendarRows.Count > 0 + ? calendarRows.Min(row => row.CreatedAt) + : calendar.CreatedAt != default ? calendar.CreatedAt : now; + var createdBy = calendarRows + .Select(row => row.CreatedBy) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) + ?? calendar.CreatedBy + ?? actor; + var isNew = calendarRows.Count == 0; + + foreach (var row in calendarRows) + { + await repository.DeleteAsync(calendar.TenantId, row.Id, cancellationToken).ConfigureAwait(false); + } + + var persistedRows = new List(calendar.Schedules.Count); + foreach (var schedule in calendar.Schedules) + { + persistedRows.Add(await repository.CreateAsync( + new QuietHoursEntity + { + Id = Guid.NewGuid(), + TenantId = calendar.TenantId, + UserId = null, + ChannelId = null, + CalendarId = calendar.CalendarId, + StartTime = ParseTimeOrDefault(schedule.StartTime), + EndTime = ParseTimeOrDefault(schedule.EndTime), + Timezone = string.IsNullOrWhiteSpace(schedule.Timezone) ? "UTC" : schedule.Timezone, + DaysOfWeek = schedule.DaysOfWeek?.ToArray() ?? [0, 1, 2, 3, 4, 5, 6], + Enabled = schedule.Enabled, + Metadata = QuietHoursRuntimeProjection.SerializeQuietHoursMetadata( + new PersistedQuietHoursMetadata( + calendar.Name, + calendar.Description, + calendar.Priority, + schedule.Name, + calendar.IncludedEventKinds?.ToList(), + calendar.ExcludedEventKinds?.ToList(), + null, + null, + null, + null, + null)), + CreatedAt = createdAt, + CreatedBy = createdBy, + UpdatedAt = now, + UpdatedBy = actor, + }, + cancellationToken).ConfigureAwait(false)); + } + + if (auditRepository is not null) + { + await auditRepository.AppendAsync( + calendar.TenantId, + isNew ? "quiet_hours_calendar_created" : "quiet_hours_calendar_updated", + actor, + new Dictionary + { + ["calendarId"] = calendar.CalendarId, + ["name"] = calendar.Name, + ["enabled"] = calendar.Enabled.ToString(), + ["scheduleCount"] = calendar.Schedules.Count.ToString(), + }, + cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "{Action} quiet hours calendar {CalendarId} for tenant {TenantId}.", + isNew ? "Created" : "Updated", + calendar.CalendarId, + calendar.TenantId); + + return MapCalendar(persistedRows.GroupBy(QuietHoursRuntimeProjection.ResolveCalendarId, StringComparer.Ordinal).Single()); + } + + public async Task DeleteCalendarAsync( + string tenantId, + string calendarId, + string? actor, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var auditRepository = scope.ServiceProvider.GetService(); + var rows = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + calendarId, + StringComparison.Ordinal)) + .ToList(); + + if (rows.Count == 0) + { + return false; + } + + foreach (var row in rows) + { + await repository.DeleteAsync(tenantId, row.Id, cancellationToken).ConfigureAwait(false); + } + + if (auditRepository is not null) + { + await auditRepository.AppendAsync( + tenantId, + "quiet_hours_calendar_deleted", + actor, + new Dictionary + { + ["calendarId"] = calendarId, + }, + cancellationToken).ConfigureAwait(false); + } + + _logger.LogInformation( + "Deleted quiet hours calendar {CalendarId} for tenant {TenantId}.", + calendarId, + tenantId); + + return true; + } + + public async Task EvaluateAsync( + string tenantId, + string eventKind, + DateTimeOffset? evaluationTime = null, + CancellationToken cancellationToken = default) + { + var now = evaluationTime ?? _timeProvider.GetUtcNow(); + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var rows = await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + + foreach (var group in rows + .GroupBy(QuietHoursRuntimeProjection.ResolveCalendarId, StringComparer.Ordinal) + .Select(MapCalendarRows) + .OrderBy(group => group.Calendar.Priority) + .ThenBy(group => group.Calendar.Name, StringComparer.OrdinalIgnoreCase)) + { + if (!group.Calendar.Enabled) + { + continue; + } + + if (group.Calendar.ExcludedEventKinds?.Any(kind => + eventKind.StartsWith(kind, StringComparison.OrdinalIgnoreCase)) == true) + { + continue; + } + + if (group.Calendar.IncludedEventKinds is { Count: > 0 } && + !group.Calendar.IncludedEventKinds.Any(kind => + eventKind.StartsWith(kind, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + foreach (var schedule in group.Schedules.Where(candidate => candidate.ProjectedSchedule.Enabled)) + { + var result = EvaluateSchedule( + group.Calendar.CalendarId, + group.Calendar.Name, + schedule.ProjectedSchedule, + now); + + if (!result.IsActive) + { + continue; + } + + _logger.LogDebug( + "Quiet hours active for {EventKind}: calendar={CalendarId}, schedule={ScheduleName}.", + eventKind, + group.Calendar.CalendarId, + schedule.ProjectedSchedule.Name); + + return result; + } + } + + return QuietHoursEvaluationResult.NotActive(); + } + + private static QuietHoursCalendar MapCalendar(IGrouping group) + => MapCalendarRows(group).Calendar; + + private static MappedCalendarGroup MapCalendarRows(IEnumerable rows) + { + var orderedRows = rows + .OrderBy(row => row.StartTime) + .ThenBy(row => row.Id) + .ToList(); + var firstRow = orderedRows[0]; + var firstMetadata = QuietHoursRuntimeProjection.DeserializeQuietHoursMetadata(firstRow.Metadata); + var calendarId = QuietHoursRuntimeProjection.ResolveCalendarId(firstRow); + var calendarName = firstMetadata.CalendarName + ?? firstMetadata.ScheduleName + ?? calendarId; + var calendar = new QuietHoursCalendar + { + CalendarId = calendarId, + TenantId = firstRow.TenantId, + Name = calendarName, + Description = firstMetadata.CalendarDescription, + Enabled = orderedRows.Any(row => row.Enabled), + Priority = firstMetadata.Priority ?? 100, + Schedules = orderedRows.Select(MapProjectedSchedule).Select(mapped => mapped.ProjectedSchedule).ToList(), + IncludedEventKinds = firstMetadata.IncludedEventKinds, + ExcludedEventKinds = firstMetadata.ExcludedEventKinds, + CreatedAt = orderedRows.Min(row => row.CreatedAt), + CreatedBy = orderedRows.Select(row => row.CreatedBy).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)), + UpdatedAt = orderedRows.Max(row => row.UpdatedAt), + UpdatedBy = orderedRows + .OrderByDescending(row => row.UpdatedAt) + .Select(row => row.UpdatedBy) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)), + }; + + return new MappedCalendarGroup( + calendar, + orderedRows.Select(MapProjectedSchedule).ToList()); + } + + private static ProjectedQuietHoursSchedule MapProjectedSchedule(QuietHoursEntity row) + { + var metadata = QuietHoursRuntimeProjection.DeserializeQuietHoursMetadata(row.Metadata); + QuietHoursScheduleEntry projectedSchedule; + if (!string.IsNullOrWhiteSpace(metadata.CompatCronExpression) && + metadata.CompatDurationSeconds is > 0 && + QuietHoursRuntimeProjection.TryProjectCompatSchedule( + metadata.CompatCronExpression, + TimeSpan.FromSeconds(metadata.CompatDurationSeconds.Value), + row.Timezone, + metadata.ScheduleName ?? metadata.CalendarName ?? QuietHoursRuntimeProjection.ResolveCalendarId(row), + row.Enabled, + out projectedSchedule)) + { + return new ProjectedQuietHoursSchedule(row, metadata, projectedSchedule); + } + + projectedSchedule = new QuietHoursScheduleEntry + { + Name = metadata.ScheduleName + ?? metadata.CalendarName + ?? QuietHoursRuntimeProjection.ResolveCalendarId(row), + StartTime = row.StartTime.ToString("HH':'mm"), + EndTime = row.EndTime.ToString("HH':'mm"), + DaysOfWeek = row.DaysOfWeek, + Timezone = row.Timezone, + Enabled = row.Enabled, + }; + + return new ProjectedQuietHoursSchedule(row, metadata, projectedSchedule); + } + + private static QuietHoursEvaluationResult EvaluateSchedule( + string calendarId, + string calendarName, + QuietHoursScheduleEntry schedule, + DateTimeOffset now) + { + if (!TimeSpan.TryParse(schedule.StartTime, out var startTime) || + !TimeSpan.TryParse(schedule.EndTime, out var endTime)) + { + return QuietHoursEvaluationResult.NotActive(); + } + + var localNow = now; + TimeZoneInfo? timeZone = null; + if (!string.IsNullOrWhiteSpace(schedule.Timezone)) + { + try + { + timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.Timezone); + localNow = TimeZoneInfo.ConvertTime(now, timeZone); + } + catch + { + timeZone = null; + } + } + + var currentTime = localNow.TimeOfDay; + var currentDay = (int)localNow.DayOfWeek; + if (schedule.DaysOfWeek is { Count: > 0 } && !schedule.DaysOfWeek.Contains(currentDay)) + { + return QuietHoursEvaluationResult.NotActive(); + } + + bool inQuietHours; + DateTimeOffset endsAt; + if (startTime <= endTime) + { + inQuietHours = currentTime >= startTime && currentTime < endTime; + endsAt = localNow.Date + endTime; + } + else + { + inQuietHours = currentTime >= startTime || currentTime < endTime; + endsAt = currentTime >= startTime + ? localNow.Date.AddDays(1) + endTime + : localNow.Date + endTime; + } + + if (!inQuietHours) + { + return QuietHoursEvaluationResult.NotActive(); + } + + if (timeZone is not null) + { + endsAt = TimeZoneInfo.ConvertTimeToUtc(endsAt.DateTime, timeZone); + } + + return QuietHoursEvaluationResult.Active( + calendarId, + calendarName, + schedule.Name, + endsAt); + } + + private static TimeOnly ParseTimeOrDefault(string? value) + => TimeOnly.TryParse(value, out var parsed) ? parsed : TimeOnly.MinValue; + + private sealed record ProjectedQuietHoursSchedule( + QuietHoursEntity Row, + PersistedQuietHoursMetadata Metadata, + QuietHoursScheduleEntry ProjectedSchedule); + + private sealed record MappedCalendarGroup( + QuietHoursCalendar Calendar, + IReadOnlyList Schedules); +} + +public sealed class PostgresQuietHoursEvaluator : IQuietHoursEvaluator +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly QuietHoursOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public PostgresQuietHoursEvaluator( + IServiceScopeFactory scopeFactory, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task EvaluateAsync( + string tenantId, + string eventKind, + CancellationToken cancellationToken = default) + { + var now = _timeProvider.GetUtcNow(); + var maintenanceResult = await CheckMaintenanceWindowsAsync(tenantId, eventKind, now, cancellationToken).ConfigureAwait(false); + if (maintenanceResult.IsSuppressed) + { + return maintenanceResult; + } + + return CheckQuietHours(eventKind, now); + } + + public async Task AddMaintenanceWindowAsync( + string tenantId, + MaintenanceWindow window, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existing = await FindMaintenanceWindowAsync(repository, tenantId, window.WindowId, cancellationToken).ConfigureAwait(false); + var now = _timeProvider.GetUtcNow(); + + var entity = new MaintenanceWindowEntity + { + Id = existing?.Id ?? Guid.NewGuid(), + TenantId = tenantId, + ExternalId = window.WindowId, + Name = existing?.Name ?? window.WindowId, + Description = window.Description, + StartAt = window.StartTime, + EndAt = window.EndTime, + SuppressNotifications = true, + SuppressChannels = null, + SuppressEventTypes = window.AffectedEventKinds?.ToArray(), + Metadata = QuietHoursRuntimeProjection.SerializeMaintenanceWindowMetadata( + new PersistedMaintenanceWindowMetadata(null, null, null)), + CreatedAt = existing?.CreatedAt ?? (window.CreatedAt == default ? now : window.CreatedAt), + CreatedBy = existing?.CreatedBy ?? window.CreatedBy, + UpdatedAt = now, + UpdatedBy = window.CreatedBy, + }; + + if (existing is null) + { + await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); + } + else + { + await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false); + } + } + + public async Task RemoveMaintenanceWindowAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existing = await FindMaintenanceWindowAsync(repository, tenantId, windowId, cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + await repository.DeleteAsync(tenantId, existing.Id, cancellationToken).ConfigureAwait(false); + } + } + + public async Task> ListMaintenanceWindowsAsync( + string tenantId, + CancellationToken cancellationToken = default) + { + var now = _timeProvider.GetUtcNow(); + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(window => window.SuppressNotifications) + .Where(window => window.EndAt > now) + .OrderBy(window => window.StartAt) + .Select(MapMaintenanceWindow) + .ToList(); + } + + private async Task CheckMaintenanceWindowsAsync( + string tenantId, + string eventKind, + DateTimeOffset now, + CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var windows = await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); + + foreach (var window in windows + .Where(candidate => candidate.SuppressNotifications) + .Where(candidate => candidate.StartAt <= now && candidate.EndAt > now) + .OrderBy(candidate => candidate.StartAt)) + { + if (window.SuppressEventTypes is { Length: > 0 } && + !window.SuppressEventTypes.Any(kind => + eventKind.StartsWith(kind, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + _logger.LogDebug( + "Event {EventKind} suppressed by maintenance window {WindowId}.", + eventKind, + window.ExternalId ?? window.Id.ToString("N")); + + return SuppressionCheckResult.Suppressed( + window.Description ?? $"Maintenance window: {window.ExternalId ?? window.Id.ToString("N")}", + "maintenance", + window.EndAt); + } + + return SuppressionCheckResult.NotSuppressed(); + } + + private SuppressionCheckResult CheckQuietHours(string eventKind, DateTimeOffset now) + { + if (!_options.Enabled || _options.Schedule is null) + { + return SuppressionCheckResult.NotSuppressed(); + } + + var schedule = _options.Schedule; + if (!schedule.Enabled) + { + return SuppressionCheckResult.NotSuppressed(); + } + + if (schedule.ExcludedEventKinds?.Any(kind => + eventKind.StartsWith(kind, StringComparison.OrdinalIgnoreCase)) == true) + { + return SuppressionCheckResult.NotSuppressed(); + } + + if (!TimeSpan.TryParse(schedule.StartTime, out var startTime) || + !TimeSpan.TryParse(schedule.EndTime, out var endTime)) + { + return SuppressionCheckResult.NotSuppressed(); + } + + var localNow = now; + TimeZoneInfo? timeZone = null; + if (!string.IsNullOrWhiteSpace(schedule.Timezone)) + { + try + { + timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.Timezone); + localNow = TimeZoneInfo.ConvertTime(now, timeZone); + } + catch + { + timeZone = null; + } + } + + var currentTime = localNow.TimeOfDay; + var currentDay = (int)localNow.DayOfWeek; + if (schedule.DaysOfWeek is { Count: > 0 } && !schedule.DaysOfWeek.Contains(currentDay)) + { + return SuppressionCheckResult.NotSuppressed(); + } + + bool inQuietHours; + DateTimeOffset endsAt; + if (startTime <= endTime) + { + inQuietHours = currentTime >= startTime && currentTime < endTime; + endsAt = localNow.Date + endTime; + } + else + { + inQuietHours = currentTime >= startTime || currentTime < endTime; + endsAt = currentTime >= startTime + ? localNow.Date.AddDays(1) + endTime + : localNow.Date + endTime; + } + + if (!inQuietHours) + { + return SuppressionCheckResult.NotSuppressed(); + } + + if (timeZone is not null) + { + endsAt = TimeZoneInfo.ConvertTimeToUtc(endsAt.DateTime, timeZone); + } + + return SuppressionCheckResult.Suppressed( + $"Quiet hours active until {endsAt:HH:mm}", + "quiet_hours", + endsAt); + } + + private static MaintenanceWindow MapMaintenanceWindow(MaintenanceWindowEntity entity) + => new() + { + WindowId = string.IsNullOrWhiteSpace(entity.ExternalId) ? entity.Id.ToString("N") : entity.ExternalId, + TenantId = entity.TenantId, + StartTime = entity.StartAt, + EndTime = entity.EndAt, + Description = entity.Description, + AffectedEventKinds = entity.SuppressEventTypes, + CreatedBy = entity.CreatedBy, + CreatedAt = entity.CreatedAt, + }; + + private static async Task FindMaintenanceWindowAsync( + IMaintenanceWindowRepository repository, + string tenantId, + string windowId, + CancellationToken cancellationToken) + { + foreach (var window in await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + { + var externalId = string.IsNullOrWhiteSpace(window.ExternalId) + ? window.Id.ToString("N") + : window.ExternalId; + if (string.Equals(externalId, windowId, StringComparison.Ordinal)) + { + return window; + } + } + + return null; + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Correlation/QuietHoursRuntimeProjection.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Correlation/QuietHoursRuntimeProjection.cs new file mode 100644 index 000000000..8dfe92a7c --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Correlation/QuietHoursRuntimeProjection.cs @@ -0,0 +1,264 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using StellaOps.Notify.Models; +using StellaOps.Notify.Persistence.Postgres.Models; + +namespace StellaOps.Notifier.Worker.Correlation; + +public static class QuietHoursRuntimeProjection +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly IReadOnlyDictionary DayOfWeekAliases = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["SUN"] = 0, + ["MON"] = 1, + ["TUE"] = 2, + ["WED"] = 3, + ["THU"] = 4, + ["FRI"] = 5, + ["SAT"] = 6, + }; + + public static string ResolveCalendarId(QuietHoursEntity entity) + => string.IsNullOrWhiteSpace(entity.CalendarId) ? entity.Id.ToString("N") : entity.CalendarId; + + public static PersistedQuietHoursMetadata DeserializeQuietHoursMetadata(string? json) + => string.IsNullOrWhiteSpace(json) + ? new PersistedQuietHoursMetadata(null, null, null, null, null, null, null, null, null, null, null) + : JsonSerializer.Deserialize(json, JsonOptions) + ?? new PersistedQuietHoursMetadata(null, null, null, null, null, null, null, null, null, null, null); + + public static string SerializeQuietHoursMetadata(PersistedQuietHoursMetadata metadata) + => JsonSerializer.Serialize(metadata, JsonOptions); + + public static PersistedMaintenanceWindowMetadata DeserializeMaintenanceWindowMetadata(string? json) + => string.IsNullOrWhiteSpace(json) + ? new PersistedMaintenanceWindowMetadata(null, null, null) + : JsonSerializer.Deserialize(json, JsonOptions) + ?? new PersistedMaintenanceWindowMetadata(null, null, null); + + public static string SerializeMaintenanceWindowMetadata(PersistedMaintenanceWindowMetadata metadata) + => JsonSerializer.Serialize(metadata, JsonOptions); + + public static bool TryProjectCompatSchedule( + NotifyQuietHoursSchedule schedule, + out QuietHoursScheduleEntry projectedSchedule) + => TryProjectCompatSchedule( + schedule.CronExpression, + schedule.Duration, + schedule.TimeZone, + schedule.Name, + schedule.Enabled, + out projectedSchedule); + + public static bool TryProjectCompatSchedule( + string cronExpression, + TimeSpan duration, + string timezone, + string scheduleName, + bool enabled, + out QuietHoursScheduleEntry projectedSchedule) + { + projectedSchedule = new QuietHoursScheduleEntry + { + Name = scheduleName, + StartTime = "00:00", + EndTime = "00:00", + DaysOfWeek = [0, 1, 2, 3, 4, 5, 6], + Timezone = string.IsNullOrWhiteSpace(timezone) ? "UTC" : timezone, + Enabled = enabled + }; + + if (duration <= TimeSpan.Zero || duration > TimeSpan.FromHours(24)) + { + return false; + } + + var fields = cronExpression + .Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + string minuteToken; + string hourToken; + string dayOfMonthToken; + string monthToken; + string dayOfWeekToken; + + if (fields.Length == 5) + { + minuteToken = fields[0]; + hourToken = fields[1]; + dayOfMonthToken = fields[2]; + monthToken = fields[3]; + dayOfWeekToken = fields[4]; + } + else if (fields.Length == 6 && (fields[0] == "0" || fields[0] == "*")) + { + minuteToken = fields[1]; + hourToken = fields[2]; + dayOfMonthToken = fields[3]; + monthToken = fields[4]; + dayOfWeekToken = fields[5]; + } + else + { + return false; + } + + if (!IsWildcard(dayOfMonthToken) || !IsWildcard(monthToken)) + { + return false; + } + + if (!TryParseFixedComponent(hourToken, 0, 23, out var hour) || + !TryParseFixedComponent(minuteToken, 0, 59, out var minute) || + !TryParseDaysOfWeek(dayOfWeekToken, out var daysOfWeek)) + { + return false; + } + + var start = new TimeOnly(hour, minute); + var startMoment = DateTime.Today + start.ToTimeSpan(); + var endMoment = startMoment + duration; + + projectedSchedule = new QuietHoursScheduleEntry + { + Name = scheduleName, + StartTime = start.ToString("HH':'mm", CultureInfo.InvariantCulture), + EndTime = TimeOnly.FromDateTime(endMoment).ToString("HH':'mm", CultureInfo.InvariantCulture), + DaysOfWeek = daysOfWeek, + Timezone = string.IsNullOrWhiteSpace(timezone) ? "UTC" : timezone, + Enabled = enabled + }; + + return true; + } + + public static QuietHoursScheduleEntry FallbackProjection( + string scheduleName, + string? timezone, + bool enabled) + => new() + { + Name = scheduleName, + StartTime = "00:00", + EndTime = "00:00", + DaysOfWeek = [0, 1, 2, 3, 4, 5, 6], + Timezone = string.IsNullOrWhiteSpace(timezone) ? "UTC" : timezone, + Enabled = enabled + }; + + private static bool IsWildcard(string token) + => token is "*" or "?"; + + private static bool TryParseFixedComponent(string token, int min, int max, out int value) + { + if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + return value >= min && value <= max; + } + + value = default; + return false; + } + + private static bool TryParseDaysOfWeek(string token, out IReadOnlyList daysOfWeek) + { + if (IsWildcard(token)) + { + daysOfWeek = [0, 1, 2, 3, 4, 5, 6]; + return true; + } + + var values = new SortedSet(); + foreach (var part in token.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (part.Contains('-', StringComparison.Ordinal)) + { + var range = part.Split('-', 2, StringSplitOptions.TrimEntries); + if (range.Length != 2 || + !TryParseDayValue(range[0], out var start) || + !TryParseDayValue(range[1], out var end)) + { + daysOfWeek = Array.Empty(); + return false; + } + + if (start <= end) + { + for (var day = start; day <= end; day++) + { + values.Add(day); + } + } + else + { + for (var day = start; day <= 6; day++) + { + values.Add(day); + } + + for (var day = 0; day <= end; day++) + { + values.Add(day); + } + } + + continue; + } + + if (!TryParseDayValue(part, out var parsedDay)) + { + daysOfWeek = Array.Empty(); + return false; + } + + values.Add(parsedDay); + } + + if (values.Count == 0) + { + daysOfWeek = Array.Empty(); + return false; + } + + daysOfWeek = values.ToArray(); + return true; + } + + private static bool TryParseDayValue(string token, out int value) + { + if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + value = value == 7 ? 0 : value; + return value is >= 0 and <= 6; + } + + if (DayOfWeekAliases.TryGetValue(token, out value)) + { + return true; + } + + value = default; + return false; + } +} + +public sealed record PersistedQuietHoursMetadata( + string? CalendarName, + string? CalendarDescription, + int? Priority, + string? ScheduleName, + List? IncludedEventKinds, + List? ExcludedEventKinds, + string? CompatCronExpression, + double? CompatDurationSeconds, + string? CompatChannelId, + string? CompatDescription, + Dictionary? CompatMetadata); + +public sealed record PersistedMaintenanceWindowMetadata( + List? ChannelIds, + List? RuleIds, + Dictionary? Metadata); diff --git a/src/Notify/StellaOps.Notify.WebService/Storage/Compat/QuietHoursMaintenancePostgresCompat.cs b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/QuietHoursMaintenancePostgresCompat.cs new file mode 100644 index 000000000..b8ef68d3c --- /dev/null +++ b/src/Notify/StellaOps.Notify.WebService/Storage/Compat/QuietHoursMaintenancePostgresCompat.cs @@ -0,0 +1,303 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Notify.Models; +using StellaOps.Notify.Persistence.Postgres.Models; +using StellaOps.Notify.Persistence.Postgres.Repositories; +using StellaOps.Notifier.Worker.Correlation; + +namespace StellaOps.Notify.WebService.Storage.Compat; + +public sealed class PostgresNotifyQuietHoursRepository : INotifyQuietHoursRepository +{ + private readonly IServiceScopeFactory _scopeFactory; + + public PostgresNotifyQuietHoursRepository(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + } + + public async Task> ListAsync( + string tenantId, + string? channelId, + bool? enabledOnly, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + IEnumerable schedules = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Select(MapCompatSchedule) + .Where(schedule => schedule is not null) + .Select(schedule => schedule!) + .OrderBy(schedule => schedule.Name, StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(channelId)) + { + schedules = schedules.Where(schedule => + string.Equals(schedule.ChannelId, channelId, StringComparison.OrdinalIgnoreCase)); + } + + if (enabledOnly is true) + { + schedules = schedules.Where(schedule => schedule.Enabled); + } + + return schedules.ToList(); + } + + public async Task GetAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + scheduleId, + StringComparison.Ordinal)) + .Select(MapCompatSchedule) + .FirstOrDefault(schedule => schedule is not null); + } + + public async Task UpsertAsync( + NotifyQuietHoursSchedule schedule, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(schedule); + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existingRows = (await repository.ListAsync(schedule.TenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + schedule.ScheduleId, + StringComparison.Ordinal)) + .ToList(); + + foreach (var row in existingRows) + { + await repository.DeleteAsync(schedule.TenantId, row.Id, cancellationToken).ConfigureAwait(false); + } + + var projection = QuietHoursRuntimeProjection.TryProjectCompatSchedule(schedule, out var projectedSchedule) + ? projectedSchedule + : QuietHoursRuntimeProjection.FallbackProjection(schedule.Name, schedule.TimeZone, schedule.Enabled); + var existing = existingRows.FirstOrDefault(); + var entity = new QuietHoursEntity + { + Id = existing?.Id ?? Guid.NewGuid(), + TenantId = schedule.TenantId, + UserId = null, + ChannelId = Guid.TryParse(schedule.ChannelId, out var channelGuid) ? channelGuid : null, + CalendarId = schedule.ScheduleId, + StartTime = TimeOnly.Parse(projection.StartTime), + EndTime = TimeOnly.Parse(projection.EndTime), + Timezone = projection.Timezone ?? "UTC", + DaysOfWeek = projection.DaysOfWeek?.ToArray() ?? [0, 1, 2, 3, 4, 5, 6], + Enabled = schedule.Enabled, + Metadata = QuietHoursRuntimeProjection.SerializeQuietHoursMetadata( + new PersistedQuietHoursMetadata( + schedule.Name, + schedule.Description, + 100, + schedule.Name, + null, + null, + schedule.CronExpression, + schedule.Duration.TotalSeconds, + schedule.ChannelId, + schedule.Description, + schedule.Metadata.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal))), + CreatedAt = existing?.CreatedAt ?? schedule.CreatedAt, + CreatedBy = existing?.CreatedBy ?? schedule.CreatedBy, + UpdatedAt = schedule.UpdatedAt, + UpdatedBy = schedule.UpdatedBy ?? schedule.CreatedBy, + }; + + await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); + return MapCompatSchedule(entity)!; + } + + public async Task DeleteAsync( + string tenantId, + string scheduleId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var rows = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals( + QuietHoursRuntimeProjection.ResolveCalendarId(row), + scheduleId, + StringComparison.Ordinal)) + .ToList(); + + var deleted = false; + foreach (var row in rows) + { + deleted |= await repository.DeleteAsync(tenantId, row.Id, cancellationToken).ConfigureAwait(false); + } + + return deleted; + } + + private static NotifyQuietHoursSchedule? MapCompatSchedule(QuietHoursEntity row) + { + var metadata = QuietHoursRuntimeProjection.DeserializeQuietHoursMetadata(row.Metadata); + if (string.IsNullOrWhiteSpace(metadata.CompatCronExpression)) + { + return null; + } + + return NotifyQuietHoursSchedule.Create( + scheduleId: QuietHoursRuntimeProjection.ResolveCalendarId(row), + tenantId: row.TenantId, + name: metadata.CalendarName + ?? metadata.ScheduleName + ?? QuietHoursRuntimeProjection.ResolveCalendarId(row), + cronExpression: metadata.CompatCronExpression, + duration: TimeSpan.FromSeconds(metadata.CompatDurationSeconds ?? 0), + timeZone: row.Timezone, + channelId: metadata.CompatChannelId, + enabled: row.Enabled, + description: metadata.CompatDescription, + metadata: metadata.CompatMetadata, + createdBy: row.CreatedBy, + createdAt: row.CreatedAt, + updatedBy: row.UpdatedBy, + updatedAt: row.UpdatedAt); + } +} + +public sealed class PostgresNotifyMaintenanceWindowRepository : INotifyMaintenanceWindowRepository +{ + private readonly IServiceScopeFactory _scopeFactory; + + public PostgresNotifyMaintenanceWindowRepository(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + } + + public async Task> ListAsync( + string tenantId, + bool? activeOnly, + DateTimeOffset now, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + IEnumerable windows = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Select(MapWindow) + .Where(window => window is not null) + .Select(window => window!) + .OrderBy(window => window.StartsAt) + .ThenBy(window => window.WindowId, StringComparer.Ordinal); + + if (activeOnly is true) + { + windows = windows.Where(window => window.IsActiveAt(now)); + } + + return windows.ToList(); + } + + public async Task GetAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Where(row => string.Equals(ResolveWindowId(row), windowId, StringComparison.Ordinal)) + .Select(MapWindow) + .FirstOrDefault(window => window is not null); + } + + public async Task UpsertAsync( + NotifyMaintenanceWindow window, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(window); + + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existing = (await repository.ListAsync(window.TenantId, cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(candidate => string.Equals(ResolveWindowId(candidate), window.WindowId, StringComparison.Ordinal)); + + var entity = new MaintenanceWindowEntity + { + Id = existing?.Id ?? Guid.NewGuid(), + TenantId = window.TenantId, + ExternalId = window.WindowId, + Name = window.Name, + Description = window.Reason, + StartAt = window.StartsAt, + EndAt = window.EndsAt, + SuppressNotifications = window.SuppressNotifications, + SuppressChannels = window.ChannelIds + .Select(value => Guid.TryParse(value, out var parsed) ? parsed : Guid.Empty) + .Where(value => value != Guid.Empty) + .ToArray(), + SuppressEventTypes = null, + Metadata = QuietHoursRuntimeProjection.SerializeMaintenanceWindowMetadata( + new PersistedMaintenanceWindowMetadata( + window.ChannelIds.ToList(), + window.RuleIds.ToList(), + window.Metadata.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal))), + CreatedAt = existing?.CreatedAt ?? window.CreatedAt, + CreatedBy = existing?.CreatedBy ?? window.CreatedBy, + UpdatedAt = window.UpdatedAt, + UpdatedBy = window.UpdatedBy ?? window.CreatedBy, + }; + + if (existing is null) + { + await repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false); + } + else + { + await repository.UpdateAsync(entity, cancellationToken).ConfigureAwait(false); + } + + return MapWindow(entity)!; + } + + public async Task DeleteAsync( + string tenantId, + string windowId, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var existing = (await repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(candidate => string.Equals(ResolveWindowId(candidate), windowId, StringComparison.Ordinal)); + + return existing is not null && + await repository.DeleteAsync(tenantId, existing.Id, cancellationToken).ConfigureAwait(false); + } + + private static NotifyMaintenanceWindow? MapWindow(MaintenanceWindowEntity entity) + { + var metadata = QuietHoursRuntimeProjection.DeserializeMaintenanceWindowMetadata(entity.Metadata); + return NotifyMaintenanceWindow.Create( + windowId: ResolveWindowId(entity), + tenantId: entity.TenantId, + name: entity.Name, + startsAt: entity.StartAt, + endsAt: entity.EndAt, + suppressNotifications: entity.SuppressNotifications, + reason: entity.Description, + channelIds: metadata.ChannelIds, + ruleIds: metadata.RuleIds, + metadata: metadata.Metadata, + createdBy: entity.CreatedBy, + createdAt: entity.CreatedAt, + updatedBy: entity.UpdatedBy, + updatedAt: entity.UpdatedAt); + } + + private static string ResolveWindowId(MaintenanceWindowEntity entity) + => string.IsNullOrWhiteSpace(entity.ExternalId) ? entity.Id.ToString("N") : entity.ExternalId; +} diff --git a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Migrations/002_quiet_hours_maintenance_runtime_metadata.sql b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Migrations/002_quiet_hours_maintenance_runtime_metadata.sql new file mode 100644 index 000000000..f4c8b92da --- /dev/null +++ b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Migrations/002_quiet_hours_maintenance_runtime_metadata.sql @@ -0,0 +1,32 @@ +ALTER TABLE notify.quiet_hours + ADD COLUMN IF NOT EXISTS calendar_id text, + ADD COLUMN IF NOT EXISTS metadata text, + ADD COLUMN IF NOT EXISTS created_by text, + ADD COLUMN IF NOT EXISTS updated_by text; + +CREATE INDEX IF NOT EXISTS idx_quiet_hours_tenant_calendar + ON notify.quiet_hours(tenant_id, calendar_id); + +UPDATE notify.quiet_hours +SET calendar_id = COALESCE(calendar_id, id::text) +WHERE calendar_id IS NULL; + +ALTER TABLE notify.maintenance_windows + ADD COLUMN IF NOT EXISTS external_id text, + ADD COLUMN IF NOT EXISTS suppress_notifications boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS metadata text, + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_by text; + +CREATE INDEX IF NOT EXISTS idx_maintenance_windows_tenant_external + ON notify.maintenance_windows(tenant_id, external_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_maintenance_windows_tenant_external + ON notify.maintenance_windows(tenant_id, external_id) + WHERE external_id IS NOT NULL; + +UPDATE notify.maintenance_windows +SET external_id = COALESCE(external_id, id::text), + updated_at = COALESCE(updated_at, created_at) +WHERE external_id IS NULL + OR updated_at IS NULL; diff --git a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/MaintenanceWindowEntity.cs b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/MaintenanceWindowEntity.cs index e4e661acd..72cf7859f 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/MaintenanceWindowEntity.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/MaintenanceWindowEntity.cs @@ -7,12 +7,17 @@ public sealed class MaintenanceWindowEntity { public required Guid Id { get; init; } public required string TenantId { get; init; } + public string? ExternalId { get; init; } public required string Name { get; init; } public string? Description { get; init; } public DateTimeOffset StartAt { get; init; } public DateTimeOffset EndAt { get; init; } + public bool SuppressNotifications { get; init; } = true; public Guid[]? SuppressChannels { get; init; } public string[]? SuppressEventTypes { get; init; } + public string? Metadata { get; init; } public DateTimeOffset CreatedAt { get; init; } public string? CreatedBy { get; init; } + public DateTimeOffset UpdatedAt { get; init; } + public string? UpdatedBy { get; init; } } diff --git a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/QuietHoursEntity.cs b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/QuietHoursEntity.cs index 94adb60f2..349f97d6b 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/QuietHoursEntity.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Postgres/Models/QuietHoursEntity.cs @@ -9,11 +9,15 @@ public sealed class QuietHoursEntity public required string TenantId { get; init; } public Guid? UserId { get; init; } public Guid? ChannelId { get; init; } + public string? CalendarId { get; init; } public required TimeOnly StartTime { get; init; } public required TimeOnly EndTime { get; init; } public string Timezone { get; init; } = "UTC"; public int[] DaysOfWeek { get; init; } = [0, 1, 2, 3, 4, 5, 6]; public bool Enabled { get; init; } = true; + public string? Metadata { get; init; } public DateTimeOffset CreatedAt { get; init; } + public string? CreatedBy { get; init; } public DateTimeOffset UpdatedAt { get; init; } + public string? UpdatedBy { get; init; } }