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