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