feat(notifier): quiet-hours + maintenance window runtime

Sprint SPRINT_20260416_010_Notify_truthful_quiet_hours_maintenance_runtime.

- Migration 002 quiet_hours_maintenance_runtime_metadata.
- QuietHoursEntity + MaintenanceWindowEntity persistence models.
- PostgresQuietHoursRuntimeServices + QuietHoursRuntimeProjection.
- Notify + Notifier WebService compat shims.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 14:39:56 +03:00
parent 9148c088da
commit 43d8398a5d
7 changed files with 1578 additions and 0 deletions

View File

@@ -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<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId,
bool? enabledOnly,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
IEnumerable<NotifyQuietHoursSchedule> 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<NotifyQuietHoursSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<NotifyQuietHoursSchedule> UpsertAsync(
NotifyQuietHoursSchedule schedule,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(schedule);
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly,
DateTimeOffset now,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
IEnumerable<NotifyMaintenanceWindow> 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<NotifyMaintenanceWindow?> GetAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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<NotifyMaintenanceWindow> UpsertAsync(
NotifyMaintenanceWindow window,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(window);
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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<bool> DeleteAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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;
}

View File

@@ -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<PostgresQuietHoursCalendarService> _logger;
public PostgresQuietHoursCalendarService(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
ILogger<PostgresQuietHoursCalendarService> 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<IReadOnlyList<QuietHoursCalendar>> ListCalendarsAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<QuietHoursCalendar?> GetCalendarAsync(
string tenantId,
string calendarId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<QuietHoursCalendar> UpsertCalendarAsync(
QuietHoursCalendar calendar,
string? actor,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(calendar);
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
var auditRepository = scope.ServiceProvider.GetService<WorkerAuditRepository>();
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<QuietHoursEntity>(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<string, string>
{
["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<bool> DeleteCalendarAsync(
string tenantId,
string calendarId,
string? actor,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
var auditRepository = scope.ServiceProvider.GetService<WorkerAuditRepository>();
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<string, string>
{
["calendarId"] = calendarId,
},
cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"Deleted quiet hours calendar {CalendarId} for tenant {TenantId}.",
calendarId,
tenantId);
return true;
}
public async Task<QuietHoursEvaluationResult> 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<IQuietHoursRepository>();
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<string, QuietHoursEntity> group)
=> MapCalendarRows(group).Calendar;
private static MappedCalendarGroup MapCalendarRows(IEnumerable<QuietHoursEntity> 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<ProjectedQuietHoursSchedule> Schedules);
}
public sealed class PostgresQuietHoursEvaluator : IQuietHoursEvaluator
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly QuietHoursOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresQuietHoursEvaluator> _logger;
public PostgresQuietHoursEvaluator(
IServiceScopeFactory scopeFactory,
IOptions<QuietHoursOptions> options,
TimeProvider timeProvider,
ILogger<PostgresQuietHoursEvaluator> 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<SuppressionCheckResult> 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<IMaintenanceWindowRepository>();
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<IMaintenanceWindowRepository>();
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<IReadOnlyList<MaintenanceWindow>> ListMaintenanceWindowsAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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<SuppressionCheckResult> CheckMaintenanceWindowsAsync(
string tenantId,
string eventKind,
DateTimeOffset now,
CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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<MaintenanceWindowEntity?> 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;
}
}

View File

@@ -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<string, int> DayOfWeekAliases =
new Dictionary<string, int>(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<PersistedQuietHoursMetadata>(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<PersistedMaintenanceWindowMetadata>(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<int> daysOfWeek)
{
if (IsWildcard(token))
{
daysOfWeek = [0, 1, 2, 3, 4, 5, 6];
return true;
}
var values = new SortedSet<int>();
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<int>();
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<int>();
return false;
}
values.Add(parsedDay);
}
if (values.Count == 0)
{
daysOfWeek = Array.Empty<int>();
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<string>? IncludedEventKinds,
List<string>? ExcludedEventKinds,
string? CompatCronExpression,
double? CompatDurationSeconds,
string? CompatChannelId,
string? CompatDescription,
Dictionary<string, string>? CompatMetadata);
public sealed record PersistedMaintenanceWindowMetadata(
List<string>? ChannelIds,
List<string>? RuleIds,
Dictionary<string, string>? Metadata);

View File

@@ -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<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId,
bool? enabledOnly,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
IEnumerable<NotifyQuietHoursSchedule> 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<NotifyQuietHoursSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<NotifyQuietHoursSchedule> UpsertAsync(
NotifyQuietHoursSchedule schedule,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(schedule);
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IQuietHoursRepository>();
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<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly,
DateTimeOffset now,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
IEnumerable<NotifyMaintenanceWindow> 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<NotifyMaintenanceWindow?> GetAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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<NotifyMaintenanceWindow> UpsertAsync(
NotifyMaintenanceWindow window,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(window);
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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<bool> DeleteAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IMaintenanceWindowRepository>();
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;
}

View File

@@ -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;

View File

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

View File

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