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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user