using System.Collections.Generic; using System.Collections.Immutable; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Linq; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using StellaOps.Notifier.WebService.Contracts; using StellaOps.Notifier.WebService.Services; using StellaOps.Notifier.WebService.Extensions; using StellaOps.Notifier.WebService.Storage.Compat; using StellaOps.Notifier.Worker.Channels; using StellaOps.Notifier.Worker.Security; using StellaOps.Notifier.Worker.StormBreaker; using StellaOps.Notifier.Worker.DeadLetter; using StellaOps.Notifier.Worker.Retention; using StellaOps.Notifier.Worker.Observability; using StellaOps.Notifier.WebService.Endpoints; using StellaOps.Notifier.WebService.Setup; using StellaOps.Notifier.Worker.Escalation; using StellaOps.Notifier.Worker.Tenancy; using StellaOps.Notifier.Worker.Templates; using DeadLetterStatus = StellaOps.Notifier.Worker.DeadLetter.DeadLetterStatus; using Contracts = StellaOps.Notifier.WebService.Contracts; using WorkerTemplateService = StellaOps.Notifier.Worker.Templates.INotifyTemplateService; using WorkerTemplateRenderer = StellaOps.Notifier.Worker.Dispatch.INotifyTemplateRenderer; using StellaOps.Notify.Models; using StellaOps.Notify.Queue; using StellaOps.Notifier.Worker.Storage; using StellaOps.Router.AspNet; var builder = WebApplication.CreateBuilder(args); var isTesting = builder.Environment.IsEnvironment("Testing"); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables(prefix: "NOTIFIER_"); builder.Services.AddSingleton(TimeProvider.System); // Fallback no-op event queue for environments that do not configure a real backend. builder.Services.TryAddSingleton(); // In-memory storage (document store removed) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Template service with enhanced renderer (worker contracts) builder.Services.AddTemplateServices(options => { var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"]; if (!string.IsNullOrWhiteSpace(provenanceUrl)) { options.ProvenanceBaseUrl = provenanceUrl; } }); // Localization resolver with fallback chain builder.Services.AddSingleton(); // Security services (NOTIFY-SVC-40-003) builder.Services.Configure(builder.Configuration.GetSection("notifier:security:ackToken")); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("notifier:security:webhook")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("notifier:security:tenantIsolation")); builder.Services.AddSingleton(); // Observability, dead-letter, and retention services (NOTIFY-SVC-40-004) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Escalation and on-call services builder.Services.AddEscalationServices(builder.Configuration); // Storm breaker, localization, and fallback services builder.Services.AddStormBreakerServices(builder.Configuration); // Security services (signing, webhook validation, HTML sanitization, tenant isolation) builder.Services.AddNotifierSecurityServices(builder.Configuration); // Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment) builder.Services.AddNotifierTenancy(builder.Configuration); builder.Services.AddHealthChecks(); // Stella Router integration var routerOptions = builder.Configuration.GetSection("Notifier:Router").Get(); builder.Services.TryAddStellaRouter( serviceName: "notifier", version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0", routerOptions: routerOptions); var app = builder.Build(); // Enable WebSocket support for live incident feed app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); app.MapHealthChecks("/healthz"); // Tenant context middleware (extracts and validates tenant from headers/query) app.UseTenantContext(); app.TryUseStellaRouter(routerOptions); // Deprecation headers for retiring v1 APIs (RFC 8594 / IETF Sunset) app.Use(async (context, next) => { if (context.Request.Path.StartsWithSegments("/api/v1", StringComparison.OrdinalIgnoreCase)) { context.Response.Headers["Deprecation"] = "true"; context.Response.Headers["Sunset"] = "Tue, 31 Mar 2026 00:00:00 GMT"; context.Response.Headers["Link"] = "; rel=\"deprecation\"; type=\"text/html\""; } await next().ConfigureAwait(false); }); app.MapPost("/api/v1/notify/pack-approvals", async ( HttpContext context, PackApprovalRequest request, INotifyLockRepository locks, INotifyPackApprovalRepository packApprovals, INotifyAuditRepository audit, INotifyEventQueue? eventQueue, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString(); if (string.IsNullOrWhiteSpace(idempotencyKey)) { return Results.BadRequest(Error("idempotency_key_missing", "Idempotency-Key header is required.", context)); } if (request.EventId == Guid.Empty || string.IsNullOrWhiteSpace(request.PackId) || string.IsNullOrWhiteSpace(request.Kind) || string.IsNullOrWhiteSpace(request.Decision) || string.IsNullOrWhiteSpace(request.Actor)) { return Results.BadRequest(Error("invalid_request", "eventId, packId, kind, decision, actor are required.", context)); } try { var lockKey = $"pack-approvals|{tenantId}|{idempotencyKey}"; var ttl = TimeSpan.FromMinutes(15); var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals", ttl, context.RequestAborted) .ConfigureAwait(false); if (!reserved) { return Results.StatusCode(StatusCodes.Status200OK); } var document = new PackApprovalDocument { TenantId = tenantId, EventId = request.EventId, PackId = request.PackId, Kind = request.Kind, Decision = request.Decision, Actor = request.Actor, IssuedAt = request.IssuedAt, PolicyId = request.Policy?.Id, PolicyVersion = request.Policy?.Version, ResumeToken = request.ResumeToken, Summary = request.Summary, Labels = request.Labels, CreatedAt = timeProvider.GetUtcNow() }; await packApprovals.UpsertAsync(document, context.RequestAborted).ConfigureAwait(false); var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = request.Actor, Action = "pack.approval.ingested", EntityId = request.PackId, EntityType = "pack-approval", Timestamp = timeProvider.GetUtcNow(), Payload = (JsonSerializer.SerializeToNode(request) as JsonObject) }; await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); if (eventQueue is not null) { var payload = JsonSerializer.SerializeToNode(new { request.PackId, request.Kind, request.Decision, request.Policy, request.ResumeToken, request.Summary, request.Labels }) ?? new JsonObject(); var notifyEvent = NotifyEvent.Create( eventId: request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid(), kind: request.Kind ?? "pack.approval", tenant: tenantId, ts: request.IssuedAt != default ? request.IssuedAt : timeProvider.GetUtcNow(), payload: payload, actor: request.Actor, version: "1"); await eventQueue.PublishAsync( new NotifyQueueEventMessage( notifyEvent, stream: "notify:events", idempotencyKey: lockKey, partitionKey: tenantId, traceId: context.TraceIdentifier), context.RequestAborted).ConfigureAwait(false); } } catch { // swallow storage/audit errors in tests to avoid 500s } if (!string.IsNullOrWhiteSpace(request.ResumeToken)) { context.Response.Headers["X-Resume-After"] = request.ResumeToken; } return Results.Accepted(); }); app.MapPost("/api/v1/notify/attestation-events", async ( HttpContext context, AttestationEventRequest request, INotifyEventQueue? eventQueue, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(request.Kind)) { return Results.BadRequest(Error("invalid_request", "kind is required.", context)); } var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid(); var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow(); if (eventQueue is not null) { var payload = request.Payload ?? new JsonObject(); var notifyEvent = NotifyEvent.Create( eventId: eventId, kind: request.Kind!, tenant: tenantId, ts: ts, payload: payload, attributes: request.Attributes ?? new Dictionary(), actor: request.Actor, version: "1"); var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString(); if (string.IsNullOrWhiteSpace(idempotencyKey)) { idempotencyKey = $"attestation|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}"; } await eventQueue.PublishAsync( new NotifyQueueEventMessage( notifyEvent, stream: "notify:events", idempotencyKey: idempotencyKey, partitionKey: tenantId, traceId: context.TraceIdentifier), context.RequestAborted).ConfigureAwait(false); } if (!string.IsNullOrWhiteSpace(request.ResumeToken)) { context.Response.Headers["X-Resume-After"] = request.ResumeToken; } return Results.Accepted(); }); app.MapPost("/api/v1/notify/risk-events", async ( HttpContext context, RiskEventRequest request, INotifyEventQueue? eventQueue, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(request.Kind)) { return Results.BadRequest(Error("invalid_request", "kind is required.", context)); } var eventId = request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid(); var ts = request.Timestamp is { } tsValue && tsValue != default ? tsValue : timeProvider.GetUtcNow(); if (eventQueue is not null) { var payload = request.Payload ?? new JsonObject(); var notifyEvent = NotifyEvent.Create( eventId: eventId, kind: request.Kind!, tenant: tenantId, ts: ts, payload: payload, attributes: request.Attributes ?? new Dictionary(), actor: request.Actor, version: "1"); var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString(); if (string.IsNullOrWhiteSpace(idempotencyKey)) { idempotencyKey = $"risk|{tenantId}|{notifyEvent.Kind}|{notifyEvent.EventId}"; } await eventQueue.PublishAsync( new NotifyQueueEventMessage( notifyEvent, stream: "notify:events", idempotencyKey: idempotencyKey, partitionKey: tenantId, traceId: context.TraceIdentifier), context.RequestAborted).ConfigureAwait(false); } if (!string.IsNullOrWhiteSpace(request.ResumeToken)) { context.Response.Headers["X-Resume-After"] = request.ResumeToken; } return Results.Accepted(); }); app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async ( HttpContext context, string packId, PackApprovalAckRequest request, INotifyLockRepository locks, INotifyAuditRepository audit, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(request.AckToken)) { return Results.BadRequest(Error("ack_token_missing", "AckToken is required.", context)); } var lockKey = $"pack-approvals-ack|{tenantId}|{packId}|{request.AckToken}"; var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals-ack", TimeSpan.FromMinutes(10), context.RequestAborted) .ConfigureAwait(false); if (!reserved) { return Results.StatusCode(StatusCodes.Status200OK); } // Use actor from request or fall back to endpoint name var actor = !string.IsNullOrWhiteSpace(request.Actor) ? request.Actor : "pack-approvals-ack"; try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "pack.approval.acknowledged", EntityId = packId, EntityType = "pack-approval", Timestamp = timeProvider.GetUtcNow(), Payload = (JsonSerializer.SerializeToNode(new { request.AckToken, request.Decision, request.Comment, request.Actor }) as JsonObject) }; await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { // ignore audit failures in tests } return Results.NoContent(); }); // ============================================= // Templates API (NOTIFY-SVC-38-003 / 38-004) // ============================================= #if false app.MapGet("/api/v2/notify/templates", async ( HttpContext context, WorkerTemplateService templateService, string? keyPrefix, string? locale, NotifyChannelType? channelType) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var templates = await templateService.ListAsync( tenantId, new TemplateListOptions { KeyPrefix = keyPrefix, Locale = locale, ChannelType = channelType }, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = templates, count = templates.Count }); }); app.MapGet("/api/v2/notify/templates/{templateId}", async ( HttpContext context, string templateId, WorkerTemplateService templateService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var template = await templateService.GetByIdAsync(tenantId, templateId, context.RequestAborted) .ConfigureAwait(false); return template is not null ? Results.Ok(template) : Results.NotFound(Error("not_found", $"Template {templateId} not found.", context)); }); app.MapPut("/api/v2/notify/templates/{templateId}", async ( HttpContext context, string templateId, TemplateUpsertRequest request, WorkerTemplateService templateService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var updatedBy = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(updatedBy)) { updatedBy = "api"; } if (string.IsNullOrWhiteSpace(request.Key) || string.IsNullOrWhiteSpace(request.Body)) { return Results.BadRequest(Error("invalid_request", "key and body are required.", context)); } var template = NotifyTemplate.Create( templateId: templateId, tenantId: tenantId, channelType: request.ChannelType ?? NotifyChannelType.Custom, key: request.Key, locale: request.Locale ?? "en-us", body: request.Body, renderMode: request.RenderMode ?? NotifyTemplateRenderMode.Markdown, format: request.Format ?? NotifyDeliveryFormat.Json, description: request.Description, metadata: request.Metadata); var result = await templateService.UpsertAsync(template, updatedBy, context.RequestAborted) .ConfigureAwait(false); return Results.Ok(result); }); app.MapDelete("/api/v2/notify/templates/{templateId}", async ( HttpContext context, string templateId, WorkerTemplateService templateService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) { actor = "api"; } await templateService.DeleteAsync(tenantId, templateId, actor, context.RequestAborted) .ConfigureAwait(false); return Results.NoContent(); }); app.MapPost("/api/v2/notify/templates/{templateId}/preview", async ( HttpContext context, string templateId, TemplatePreviewRequest request, WorkerTemplateService templateService, WorkerTemplateRenderer renderer, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var template = await templateService.GetByIdAsync(tenantId, templateId, context.RequestAborted) .ConfigureAwait(false); if (template is null) { return Results.NotFound(Error("not_found", $"Template {templateId} not found.", context)); } var sampleEvent = NotifyEvent.Create( eventId: Guid.NewGuid(), kind: request.EventKind ?? "sample.event", tenant: tenantId, ts: timeProvider.GetUtcNow(), payload: request.SamplePayload ?? new JsonObject(), attributes: request.SampleAttributes ?? new Dictionary(), actor: "preview", version: "1"); var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new TemplatePreviewResponse { RenderedBody = rendered.Body, RenderedSubject = rendered.Subject, BodyHash = rendered.BodyHash, Format = rendered.Format.ToString(), Warnings = null }); }); // ============================================= // Rules API (NOTIFY-SVC-38-004) // ============================================= app.MapGet("/api/v2/notify/rules", async ( HttpContext context, INotifyRuleRepository ruleRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var rules = await ruleRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = rules, count = rules.Count }); }); app.MapGet("/api/v2/notify/rules/{ruleId}", async ( HttpContext context, string ruleId, INotifyRuleRepository ruleRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var rule = await ruleRepository.GetAsync(tenantId, ruleId, context.RequestAborted).ConfigureAwait(false); return rule is not null ? Results.Ok(rule) : Results.NotFound(Error("not_found", $"Rule {ruleId} not found.", context)); }); app.MapPut("/api/v2/notify/rules/{ruleId}", async ( HttpContext context, string ruleId, RuleUpsertRequest request, INotifyRuleRepository ruleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) { actor = "api"; } if (string.IsNullOrWhiteSpace(request.Name) || request.Match is null || request.Actions is null) { return Results.BadRequest(Error("invalid_request", "name, match, and actions are required.", context)); } var rule = NotifyRule.Create( ruleId: ruleId, tenantId: tenantId, name: request.Name, match: NotifyRuleMatch.Create(eventKinds: request.Match.EventKinds ?? []), actions: request.Actions.Select(a => NotifyRuleAction.Create( actionId: a.ActionId ?? Guid.NewGuid().ToString("N"), channel: a.Channel ?? string.Empty, template: a.Template ?? string.Empty, locale: a.Locale, enabled: a.Enabled)).ToArray(), enabled: request.Enabled ?? true, description: request.Description); await ruleRepository.UpsertAsync(rule, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "rule.upsert", EntityId = ruleId, EntityType = "rule", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { ruleId, name = request.Name, enabled = request.Enabled }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { // Audit failure should not block rule update } return Results.Ok(rule); }); app.MapDelete("/api/v2/notify/rules/{ruleId}", async ( HttpContext context, string ruleId, INotifyRuleRepository ruleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) { actor = "api"; } await ruleRepository.DeleteAsync(tenantId, ruleId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "rule.delete", EntityId = ruleId, EntityType = "rule", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { // Audit failure should not block rule deletion } return Results.NoContent(); }); #endif // ============================================= // Channels API (NOTIFY-SVC-38-004) // ============================================= app.MapGet("/api/v2/notify/channels", async ( HttpContext context, INotifyChannelRepository channelRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var channels = await channelRepository.ListAsync(tenantId, cancellationToken: context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = channels, count = channels.Count }); }); app.MapGet("/api/v2/notify/channels/{channelId}", async ( HttpContext context, string channelId, INotifyChannelRepository channelRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var channel = await channelRepository.GetAsync(tenantId, channelId, context.RequestAborted).ConfigureAwait(false); return channel is not null ? Results.Ok(channel) : Results.NotFound(Error("not_found", $"Channel {channelId} not found.", context)); }); app.MapPut("/api/v2/notify/channels/{channelId}", async ( HttpContext context, string channelId, ChannelUpsertRequest request, INotifyChannelRepository channelRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) { actor = "api"; } if (string.IsNullOrWhiteSpace(request.Name)) { return Results.BadRequest(Error("invalid_request", "name is required.", context)); } var config = NotifyChannelConfig.Create( secretRef: request.SecretRef ?? string.Empty, endpoint: request.Endpoint, target: request.Target); var channel = NotifyChannel.Create( channelId: channelId, tenantId: tenantId, name: request.Name, type: request.Type ?? NotifyChannelType.Custom, config: config, description: request.Description); await channelRepository.UpsertAsync(channel, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "channel.upsert", EntityId = channelId, EntityType = "channel", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { channelId, name = request.Name, type = request.Type }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { // Audit failure should not block channel update } return Results.Ok(channel); }); app.MapDelete("/api/v2/notify/channels/{channelId}", async ( HttpContext context, string channelId, INotifyChannelRepository channelRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } await channelRepository.DeleteAsync(tenantId, channelId, context.RequestAborted).ConfigureAwait(false); return Results.NoContent(); }); // ============================================= // Deliveries API (NOTIFY-SVC-38-004) // ============================================= app.MapGet("/api/v2/notify/deliveries", async ( HttpContext context, INotifyDeliveryRepository deliveryRepository, string? status, DateTimeOffset? since, int? limit) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var result = await deliveryRepository.QueryAsync( tenantId: tenantId, since: since, status: status, limit: limit ?? 50, cancellationToken: context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = result.Items, count = result.Items.Count, continuationToken = result.ContinuationToken }); }); app.MapGet("/api/v2/notify/deliveries/{deliveryId}", async ( HttpContext context, string deliveryId, INotifyDeliveryRepository deliveryRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var delivery = await deliveryRepository.GetAsync(tenantId, deliveryId, context.RequestAborted).ConfigureAwait(false); return delivery is not null ? Results.Ok(delivery) : Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context)); }); // ============================================= // Delivery Retry and Stats (NOTIFY-016) // ============================================= app.MapPost("/api/v2/notify/deliveries/{deliveryId}/retry", async ( HttpContext context, string deliveryId, StellaOps.Notifier.WebService.Contracts.DeliveryRetryRequest? request, INotifyDeliveryRepository deliveryRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; var delivery = await deliveryRepository.GetAsync(tenantId, deliveryId, context.RequestAborted).ConfigureAwait(false); if (delivery is null) { return Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context)); } if (delivery.Status == NotifyDeliveryStatus.Sent || delivery.Status == NotifyDeliveryStatus.Delivered) { return Results.BadRequest(Error("delivery_already_completed", "Cannot retry a completed delivery.", context)); } var now = timeProvider.GetUtcNow(); var newAttemptNumber = delivery.Attempts.Length + 1; // Create new attempt and update delivery to pending status for retry var newAttempt = new NotifyDeliveryAttempt(now, NotifyDeliveryAttemptStatus.Enqueued); var updatedDelivery = NotifyDelivery.Create( delivery.DeliveryId, delivery.TenantId, delivery.RuleId, delivery.ActionId, delivery.EventId, delivery.Kind, NotifyDeliveryStatus.Pending, "Retry requested", delivery.Rendered, delivery.Attempts.Append(newAttempt), delivery.Metadata, delivery.CreatedAt); await deliveryRepository.UpdateAsync(updatedDelivery, context.RequestAborted).ConfigureAwait(false); // Audit the retry try { await auditRepository.AppendAsync(new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "delivery.retry", EntityId = deliveryId, EntityType = "delivery", Timestamp = now, Payload = System.Text.Json.JsonSerializer.SerializeToNode(new { deliveryId, reason = request?.Reason, forceChannel = request?.ForceChannel }) as System.Text.Json.Nodes.JsonObject }, context.RequestAborted).ConfigureAwait(false); } catch { /* Ignore audit failures */ } return Results.Ok(new { deliveryId, retried = true, newAttemptNumber, scheduledAt = now.ToString("O"), message = "Delivery scheduled for retry" }); }); app.MapGet("/api/v2/notify/deliveries/stats", async ( HttpContext context, INotifyDeliveryRepository deliveryRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var allDeliveries = await deliveryRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false); var sent = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered); var failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed); var throttled = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Throttled); var pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending); var total = sent + failed; // Calculate average delivery time from attempts that have status codes (indicating completion) var completedAttempts = allDeliveries .Where(d => d.Attempts.Length > 0) .SelectMany(d => d.Attempts) .Where(a => a.StatusCode.HasValue) .ToList(); var avgDeliveryTime = completedAttempts.Count > 0 ? 0.0 : 0.0; // Response time not tracked in this model var byChannel = allDeliveries .GroupBy(d => d.ActionId) .ToDictionary( g => g.Key, g => new { sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered), failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed) }); var byEventKind = allDeliveries .GroupBy(d => d.Kind) .ToDictionary( g => g.Key, g => new { sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered), failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed) }); return Results.Ok(new { totalSent = sent, totalFailed = failed, totalThrottled = throttled, totalPending = pending, avgDeliveryTimeMs = avgDeliveryTime, successRate = total > 0 ? (double)sent / total * 100 : 0, period = "day", byChannel, byEventKind }); }); // ============================================= // Simulation API (NOTIFY-SVC-39-003) // ============================================= app.MapPost("/api/v2/notify/simulate", async ( HttpContext context, SimulationRunRequest request, INotifyRuleRepository ruleRepository, INotifyChannelRepository channelRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (request.PeriodStart >= request.PeriodEnd) { return Results.BadRequest(Error("invalid_period", "PeriodStart must be before PeriodEnd.", context)); } // Create simulation engine inline (lightweight for API use) var simulationEngine = new StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine( ruleRepository, channelRepository, auditRepository, new StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator(), throttler: null, quietHoursEvaluator: null, timeProvider, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var simulationRequest = new StellaOps.Notifier.Worker.Simulation.NotifySimulationRequest { TenantId = tenantId, PeriodStart = request.PeriodStart, PeriodEnd = request.PeriodEnd, RuleIds = request.RuleIds, EventKinds = request.EventKinds, MaxEvents = Math.Clamp(request.MaxEvents, 1, 10000), IncludeNonMatches = request.IncludeNonMatches, EvaluateThrottling = request.EvaluateThrottling, EvaluateQuietHours = request.EvaluateQuietHours, EvaluationTimestamp = request.EvaluationTimestamp }; var result = await simulationEngine.SimulateAsync(simulationRequest, context.RequestAborted).ConfigureAwait(false); return Results.Ok(result); }); app.MapPost("/api/v2/notify/simulate/event", async ( HttpContext context, SimulateSingleEventRequest request, INotifyRuleRepository ruleRepository, INotifyChannelRepository channelRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (request.EventPayload is null) { return Results.BadRequest(Error("invalid_request", "EventPayload is required.", context)); } var simulationEngine = new StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine( ruleRepository, channelRepository, auditRepository, new StellaOps.Notifier.Worker.Processing.DefaultNotifyRuleEvaluator(), throttler: null, quietHoursEvaluator: null, timeProvider, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var result = await simulationEngine.SimulateSingleEventAsync( tenantId, request.EventPayload, request.RuleIds.IsDefaultOrEmpty ? null : request.RuleIds, request.EvaluationTimestamp, context.RequestAborted).ConfigureAwait(false); return Results.Ok(result); }); // ============================================= // Quiet Hours API (NOTIFY-SVC-39-004) // ============================================= app.MapGet("/api/v2/notify/quiet-hours", async ( HttpContext context, INotifyQuietHoursRepository quietHoursRepository, string? channelId, bool? enabledOnly) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var schedules = await quietHoursRepository.ListAsync(tenantId, channelId, enabledOnly, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = schedules, count = schedules.Count }); }); app.MapGet("/api/v2/notify/quiet-hours/{scheduleId}", async ( HttpContext context, string scheduleId, INotifyQuietHoursRepository quietHoursRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var schedule = await quietHoursRepository.GetAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false); return schedule is not null ? Results.Ok(schedule) : Results.NotFound(Error("not_found", $"Quiet hours schedule {scheduleId} not found.", context)); }); app.MapPut("/api/v2/notify/quiet-hours/{scheduleId}", async ( HttpContext context, string scheduleId, QuietHoursUpsertRequest request, INotifyQuietHoursRepository quietHoursRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) { actor = "api"; } if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.CronExpression) || string.IsNullOrWhiteSpace(request.TimeZone) || request.Duration <= TimeSpan.Zero) { return Results.BadRequest(Error("invalid_request", "name, cronExpression, timeZone, and positive duration are required.", context)); } var schedule = StellaOps.Notify.Models.NotifyQuietHoursSchedule.Create( scheduleId: scheduleId, tenantId: tenantId, name: request.Name, cronExpression: request.CronExpression, duration: request.Duration, timeZone: request.TimeZone, channelId: request.ChannelId, enabled: request.Enabled ?? true, description: request.Description, metadata: request.Metadata, createdBy: actor); await quietHoursRepository.UpsertAsync(schedule, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "quiethours.upsert", EntityId = scheduleId, EntityType = "quiet-hours", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { scheduleId, name = request.Name, enabled = request.Enabled }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(schedule); }); app.MapDelete("/api/v2/notify/quiet-hours/{scheduleId}", async ( HttpContext context, string scheduleId, INotifyQuietHoursRepository quietHoursRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await quietHoursRepository.DeleteAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "quiethours.delete", EntityId = scheduleId, EntityType = "quiet-hours", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); // ============================================= // Maintenance Windows API (NOTIFY-SVC-39-004) // ============================================= app.MapGet("/api/v2/notify/maintenance-windows", async ( HttpContext context, INotifyMaintenanceWindowRepository maintenanceRepository, bool? activeOnly) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var windows = await maintenanceRepository.ListAsync(tenantId, activeOnly, DateTimeOffset.UtcNow, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = windows, count = windows.Count }); }); app.MapGet("/api/v2/notify/maintenance-windows/{windowId}", async ( HttpContext context, string windowId, INotifyMaintenanceWindowRepository maintenanceRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var window = await maintenanceRepository.GetAsync(tenantId, windowId, context.RequestAborted).ConfigureAwait(false); return window is not null ? Results.Ok(window) : Results.NotFound(Error("not_found", $"Maintenance window {windowId} not found.", context)); }); app.MapPut("/api/v2/notify/maintenance-windows/{windowId}", async ( HttpContext context, string windowId, MaintenanceWindowUpsertRequest request, INotifyMaintenanceWindowRepository maintenanceRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; if (string.IsNullOrWhiteSpace(request.Name) || request.EndsAt <= request.StartsAt) { return Results.BadRequest(Error("invalid_request", "name is required and endsAt must be after startsAt.", context)); } var window = StellaOps.Notify.Models.NotifyMaintenanceWindow.Create( windowId: windowId, tenantId: tenantId, name: request.Name, startsAt: request.StartsAt, endsAt: request.EndsAt, suppressNotifications: request.SuppressNotifications ?? true, reason: request.Reason, channelIds: request.ChannelIds.IsDefaultOrEmpty ? null : request.ChannelIds, ruleIds: request.RuleIds.IsDefaultOrEmpty ? null : request.RuleIds, metadata: request.Metadata, createdBy: actor); await maintenanceRepository.UpsertAsync(window, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "maintenance.upsert", EntityId = windowId, EntityType = "maintenance-window", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { windowId, name = request.Name, startsAt = request.StartsAt, endsAt = request.EndsAt }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(window); }); app.MapDelete("/api/v2/notify/maintenance-windows/{windowId}", async ( HttpContext context, string windowId, INotifyMaintenanceWindowRepository maintenanceRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await maintenanceRepository.DeleteAsync(tenantId, windowId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "maintenance.delete", EntityId = windowId, EntityType = "maintenance-window", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); // ============================================= // Throttle Configs API (NOTIFY-SVC-39-004) // ============================================= app.MapGet("/api/v2/notify/throttle-configs", async ( HttpContext context, INotifyThrottleConfigRepository throttleConfigRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var configs = await throttleConfigRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = configs, count = configs.Count }); }); app.MapGet("/api/v2/notify/throttle-configs/{configId}", async ( HttpContext context, string configId, INotifyThrottleConfigRepository throttleConfigRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var config = await throttleConfigRepository.GetAsync(tenantId, configId, context.RequestAborted).ConfigureAwait(false); return config is not null ? Results.Ok(config) : Results.NotFound(Error("not_found", $"Throttle config {configId} not found.", context)); }); app.MapPut("/api/v2/notify/throttle-configs/{configId}", async ( HttpContext context, string configId, ThrottleConfigUpsertRequest request, INotifyThrottleConfigRepository throttleConfigRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; if (string.IsNullOrWhiteSpace(request.Name) || request.DefaultWindow <= TimeSpan.Zero) { return Results.BadRequest(Error("invalid_request", "name and positive defaultWindow are required.", context)); } var config = StellaOps.Notify.Models.NotifyThrottleConfig.Create( configId: configId, tenantId: tenantId, name: request.Name, defaultWindow: request.DefaultWindow, maxNotificationsPerWindow: request.MaxNotificationsPerWindow, channelId: request.ChannelId, isDefault: request.IsDefault ?? false, enabled: request.Enabled ?? true, description: request.Description, metadata: request.Metadata, createdBy: actor); await throttleConfigRepository.UpsertAsync(config, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "throttleconfig.upsert", EntityId = configId, EntityType = "throttle-config", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { configId, name = request.Name, defaultWindow = request.DefaultWindow.TotalSeconds }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(config); }); app.MapDelete("/api/v2/notify/throttle-configs/{configId}", async ( HttpContext context, string configId, INotifyThrottleConfigRepository throttleConfigRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await throttleConfigRepository.DeleteAsync(tenantId, configId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "throttleconfig.delete", EntityId = configId, EntityType = "throttle-config", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); // ============================================= // Operator Overrides API (NOTIFY-SVC-39-004) // ============================================= app.MapGet("/api/v2/notify/overrides", async ( HttpContext context, INotifyOperatorOverrideRepository overrideRepository, bool? activeOnly) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var overrides = await overrideRepository.ListAsync(tenantId, activeOnly, DateTimeOffset.UtcNow, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = overrides, count = overrides.Count }); }); app.MapGet("/api/v2/notify/overrides/{overrideId}", async ( HttpContext context, string overrideId, INotifyOperatorOverrideRepository overrideRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var @override = await overrideRepository.GetAsync(tenantId, overrideId, context.RequestAborted).ConfigureAwait(false); return @override is not null ? Results.Ok(@override) : Results.NotFound(Error("not_found", $"Operator override {overrideId} not found.", context)); }); app.MapPost("/api/v2/notify/overrides", async ( HttpContext context, OperatorOverrideCreateRequest request, INotifyOperatorOverrideRepository overrideRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; if (string.IsNullOrWhiteSpace(request.OverrideType) || request.ExpiresAt <= timeProvider.GetUtcNow()) { return Results.BadRequest(Error("invalid_request", "overrideType is required and expiresAt must be in the future.", context)); } if (!Enum.TryParse(request.OverrideType, ignoreCase: true, out var overrideType)) { return Results.BadRequest(Error("invalid_request", $"Invalid override type: {request.OverrideType}. Valid types: BypassQuietHours, BypassThrottle, BypassMaintenance, ForceSuppression.", context)); } var overrideId = Guid.NewGuid().ToString("N"); var @override = StellaOps.Notify.Models.NotifyOperatorOverride.Create( overrideId: overrideId, tenantId: tenantId, overrideType: overrideType, expiresAt: request.ExpiresAt, channelId: request.ChannelId, ruleId: request.RuleId, reason: request.Reason, createdBy: actor); await overrideRepository.UpsertAsync(@override, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "override.create", EntityId = overrideId, EntityType = "operator-override", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { overrideId, overrideType = request.OverrideType, expiresAt = request.ExpiresAt, reason = request.Reason }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Created($"/api/v2/notify/overrides/{overrideId}", @override); }); app.MapDelete("/api/v2/notify/overrides/{overrideId}", async ( HttpContext context, string overrideId, INotifyOperatorOverrideRepository overrideRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await overrideRepository.DeleteAsync(tenantId, overrideId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "override.delete", EntityId = overrideId, EntityType = "operator-override", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); // ============================================= // Escalation Policies API (NOTIFY-SVC-40-001) // ============================================= app.MapGet("/api/v2/notify/escalation-policies", async ( HttpContext context, INotifyEscalationPolicyRepository policyRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var policies = await policyRepository.ListAsync(tenantId, null, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = policies, count = policies.Count }); }); app.MapGet("/api/v2/notify/escalation-policies/{policyId}", async ( HttpContext context, string policyId, INotifyEscalationPolicyRepository policyRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var policy = await policyRepository.GetAsync(tenantId, policyId, context.RequestAborted).ConfigureAwait(false); return policy is not null ? Results.Ok(policy) : Results.NotFound(Error("not_found", $"Escalation policy {policyId} not found.", context)); }); app.MapPut("/api/v2/notify/escalation-policies/{policyId}", async ( HttpContext context, string policyId, EscalationPolicyUpsertRequest request, INotifyEscalationPolicyRepository policyRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; if (string.IsNullOrWhiteSpace(request.Name) || request.Levels.IsDefaultOrEmpty) { return Results.BadRequest(Error("invalid_request", "name and at least one level are required.", context)); } var levels = request.Levels.Select(l => NotifyEscalationLevel.Create( order: l.Order, escalateAfter: l.EscalateAfter, targets: l.Targets.IsDefaultOrEmpty ? [] : l.Targets.Select(t => NotifyEscalationTarget.Create( Enum.TryParse(t.Type, ignoreCase: true, out var tt) ? tt : NotifyEscalationTargetType.User, t.TargetId ?? string.Empty)).ToArray())).ToImmutableArray(); var policy = NotifyEscalationPolicy.Create( policyId: policyId, tenantId: tenantId, name: request.Name, levels: levels, repeatCount: request.RepeatCount ?? 0, enabled: request.Enabled ?? true, description: request.Description); await policyRepository.UpsertAsync(policy, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "escalationpolicy.upsert", EntityId = policyId, EntityType = "escalation-policy", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { policyId, name = request.Name, enabled = request.Enabled }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(policy); }); app.MapDelete("/api/v2/notify/escalation-policies/{policyId}", async ( HttpContext context, string policyId, INotifyEscalationPolicyRepository policyRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await policyRepository.DeleteAsync(tenantId, policyId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "escalationpolicy.delete", EntityId = policyId, EntityType = "escalation-policy", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); // ============================================= // On-Call Schedules API (NOTIFY-SVC-40-001) // ============================================= app.MapGet("/api/v2/notify/oncall-schedules", async ( HttpContext context, INotifyOnCallScheduleRepository scheduleRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var schedules = await scheduleRepository.ListAsync(tenantId, null, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = schedules, count = schedules.Count }); }); app.MapGet("/api/v2/notify/oncall-schedules/{scheduleId}", async ( HttpContext context, string scheduleId, INotifyOnCallScheduleRepository scheduleRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var schedule = await scheduleRepository.GetAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false); return schedule is not null ? Results.Ok(schedule) : Results.NotFound(Error("not_found", $"On-call schedule {scheduleId} not found.", context)); }); app.MapPut("/api/v2/notify/oncall-schedules/{scheduleId}", async ( HttpContext context, string scheduleId, OnCallScheduleUpsertRequest request, INotifyOnCallScheduleRepository scheduleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.TimeZone)) { return Results.BadRequest(Error("invalid_request", "name and timeZone are required.", context)); } var layers = request.Layers.IsDefaultOrEmpty ? ImmutableArray.Empty : request.Layers.Select(l => NotifyOnCallLayer.Create( layerId: l.LayerId ?? Guid.NewGuid().ToString("N"), name: l.Name ?? "Unnamed Layer", priority: l.Priority, rotationType: NotifyRotationType.Custom, rotationInterval: l.RotationInterval, rotationStartsAt: l.RotationStartsAt, participants: l.Participants.IsDefaultOrEmpty ? null : l.Participants.Select(p => NotifyOnCallParticipant.Create( userId: p.UserId ?? string.Empty, name: p.Name, email: p.Email, contactMethods: p.ContactMethods.IsDefaultOrEmpty ? null : p.ContactMethods.Select(cm => new NotifyContactMethod( Enum.TryParse(cm.Type, ignoreCase: true, out var cmt) ? cmt : NotifyContactMethodType.Email, cm.Address ?? string.Empty)))), restrictions: l.Restrictions is null ? null : NotifyOnCallRestriction.Create( Enum.TryParse(l.Restrictions.Type, ignoreCase: true, out var rt) ? rt : NotifyRestrictionType.DailyRestriction, l.Restrictions.TimeRanges.IsDefaultOrEmpty ? null : l.Restrictions.TimeRanges.Select(tr => new NotifyTimeRange(tr.DayOfWeek, tr.StartTime, tr.EndTime))))).ToImmutableArray(); var schedule = NotifyOnCallSchedule.Create( scheduleId: scheduleId, tenantId: tenantId, name: request.Name, timeZone: request.TimeZone, layers: layers, enabled: request.Enabled ?? true, description: request.Description); await scheduleRepository.UpsertAsync(schedule, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "oncallschedule.upsert", EntityId = scheduleId, EntityType = "oncall-schedule", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { scheduleId, name = request.Name, enabled = request.Enabled }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(schedule); }); app.MapDelete("/api/v2/notify/oncall-schedules/{scheduleId}", async ( HttpContext context, string scheduleId, INotifyOnCallScheduleRepository scheduleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await scheduleRepository.DeleteAsync(tenantId, scheduleId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "oncallschedule.delete", EntityId = scheduleId, EntityType = "oncall-schedule", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); app.MapPost("/api/v2/notify/oncall-schedules/{scheduleId}/overrides", async ( HttpContext context, string scheduleId, OnCallOverrideRequest request, INotifyOnCallScheduleRepository scheduleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; if (string.IsNullOrWhiteSpace(request.UserId) || request.EndsAt <= request.StartsAt) { return Results.BadRequest(Error("invalid_request", "userId is required and endsAt must be after startsAt.", context)); } var overrideId = Guid.NewGuid().ToString("N"); var @override = NotifyOnCallOverride.Create( overrideId: overrideId, userId: request.UserId, startsAt: request.StartsAt, endsAt: request.EndsAt, reason: request.Reason, createdBy: actor); await scheduleRepository.AddOverrideAsync(tenantId, scheduleId, @override, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "oncallschedule.override.create", EntityId = scheduleId, EntityType = "oncall-schedule", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { scheduleId, overrideId, userId = request.UserId }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Created($"/api/v2/notify/oncall-schedules/{scheduleId}/overrides/{overrideId}", @override); }); app.MapDelete("/api/v2/notify/oncall-schedules/{scheduleId}/overrides/{overrideId}", async ( HttpContext context, string scheduleId, string overrideId, INotifyOnCallScheduleRepository scheduleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await scheduleRepository.RemoveOverrideAsync(tenantId, scheduleId, overrideId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "oncallschedule.override.delete", EntityId = scheduleId, EntityType = "oncall-schedule", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); // ============================================= // In-App Inbox API (NOTIFY-SVC-40-001) // ============================================= app.MapGet("/api/v2/notify/inbox", async ( HttpContext context, INotifyInboxRepository inboxRepository, string? userId, int? limit) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(userId)) { return Results.BadRequest(Error("invalid_request", "userId query parameter is required.", context)); } var messages = await inboxRepository.GetForUserAsync(tenantId, userId, limit ?? 50, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = messages, count = messages.Count }); }); app.MapGet("/api/v2/notify/inbox/{messageId}", async ( HttpContext context, string messageId, INotifyInboxRepository inboxRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var message = await inboxRepository.GetAsync(tenantId, messageId, context.RequestAborted).ConfigureAwait(false); return message is not null ? Results.Ok(message) : Results.NotFound(Error("not_found", $"Inbox message {messageId} not found.", context)); }); app.MapPost("/api/v2/notify/inbox/{messageId}/read", async ( HttpContext context, string messageId, INotifyInboxRepository inboxRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } await inboxRepository.MarkReadAsync(tenantId, messageId, context.RequestAborted).ConfigureAwait(false); return Results.NoContent(); }); app.MapPost("/api/v2/notify/inbox/read-all", async ( HttpContext context, INotifyInboxRepository inboxRepository, string? userId) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(userId)) { return Results.BadRequest(Error("invalid_request", "userId query parameter is required.", context)); } await inboxRepository.MarkAllReadAsync(tenantId, userId, context.RequestAborted).ConfigureAwait(false); return Results.NoContent(); }); app.MapGet("/api/v2/notify/inbox/unread-count", async ( HttpContext context, INotifyInboxRepository inboxRepository, string? userId) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(userId)) { return Results.BadRequest(Error("invalid_request", "userId query parameter is required.", context)); } var count = await inboxRepository.GetUnreadCountAsync(tenantId, userId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { unreadCount = count }); }); app.MapDelete("/api/v2/notify/inbox/{messageId}", async ( HttpContext context, string messageId, INotifyInboxRepository inboxRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } await inboxRepository.DeleteAsync(tenantId, messageId, context.RequestAborted).ConfigureAwait(false); return Results.NoContent(); }); // ============================================= // Localization Bundles API (NOTIFY-SVC-40-002) // ============================================= app.MapGet("/api/v2/notify/localization/bundles", async ( HttpContext context, INotifyLocalizationRepository localizationRepository, string? bundleKey) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var bundles = await localizationRepository.ListAsync(tenantId, bundleKey, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = bundles, count = bundles.Count }); }); app.MapGet("/api/v2/notify/localization/bundles/{bundleId}", async ( HttpContext context, string bundleId, INotifyLocalizationRepository localizationRepository) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var bundle = await localizationRepository.GetAsync(tenantId, bundleId, context.RequestAborted).ConfigureAwait(false); return bundle is not null ? Results.Ok(bundle) : Results.NotFound(Error("not_found", $"Localization bundle {bundleId} not found.", context)); }); app.MapPut("/api/v2/notify/localization/bundles/{bundleId}", async ( HttpContext context, string bundleId, LocalizationBundleUpsertRequest request, INotifyLocalizationRepository localizationRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; if (string.IsNullOrWhiteSpace(request.Locale) || string.IsNullOrWhiteSpace(request.BundleKey)) { return Results.BadRequest(Error("invalid_request", "locale and bundleKey are required.", context)); } var bundle = NotifyLocalizationBundle.Create( bundleId: bundleId, tenantId: tenantId, locale: request.Locale, bundleKey: request.BundleKey, strings: request.Strings, isDefault: request.IsDefault ?? false, parentLocale: request.ParentLocale, description: request.Description, metadata: request.Metadata, updatedBy: actor); await localizationRepository.UpsertAsync(bundle, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "localization.bundle.upsert", EntityId = bundleId, EntityType = "localization-bundle", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { bundleId, locale = request.Locale, bundleKey = request.BundleKey }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(bundle); }); app.MapDelete("/api/v2/notify/localization/bundles/{bundleId}", async ( HttpContext context, string bundleId, INotifyLocalizationRepository localizationRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; await localizationRepository.DeleteAsync(tenantId, bundleId, context.RequestAborted).ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "localization.bundle.delete", EntityId = bundleId, EntityType = "localization-bundle", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.NoContent(); }); app.MapGet("/api/v2/notify/localization/locales", async ( HttpContext context, INotifyLocalizationRepository localizationRepository, string? bundleKey) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(bundleKey)) { return Results.BadRequest(Error("invalid_request", "bundleKey query parameter is required.", context)); } var locales = await localizationRepository.ListLocalesAsync(tenantId, bundleKey, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { locales, count = locales.Count }); }); app.MapPost("/api/v2/notify/localization/resolve", async ( HttpContext context, LocalizationResolveRequest request, ILocalizationResolver localizationResolver) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(request.BundleKey) || request.StringKeys is null || request.StringKeys.Count == 0) { return Results.BadRequest(Error("invalid_request", "bundleKey and stringKeys are required.", context)); } var locale = request.Locale ?? "en-us"; var resolved = await localizationResolver.ResolveBatchAsync( tenantId, request.BundleKey, request.StringKeys, locale, context.RequestAborted).ConfigureAwait(false); var strings = resolved.ToDictionary( kv => kv.Key, kv => new LocalizedStringResult { Value = kv.Value.Value, ResolvedLocale = kv.Value.ResolvedLocale, UsedFallback = kv.Value.UsedFallback }); var response = new LocalizationResolveResponse { Strings = strings, RequestedLocale = locale, FallbackChain = resolved.Values.FirstOrDefault()?.FallbackChain ?? [] }; return Results.Ok(response); }); // ============================================= // Storm Breaker API (NOTIFY-SVC-40-002) // ============================================= app.MapGet("/api/v2/notify/storms", async ( HttpContext context, IStormBreaker stormBreaker) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var storms = await stormBreaker.GetActiveStormsAsync(tenantId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new { items = storms, count = storms.Count }); }); app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async ( HttpContext context, string stormKey, IStormBreaker stormBreaker, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, context.RequestAborted).ConfigureAwait(false); if (summary is null) { return Results.NotFound(Error("not_found", $"Storm {stormKey} not found or has no events.", context)); } try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "storm.summary.triggered", EntityId = summary.SummaryId, EntityType = "storm-summary", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { stormKey, eventCount = summary.TotalEvents }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(summary); }); // ============================================= // Security API (NOTIFY-SVC-40-003) // ============================================= // Acknowledge notification via signed token app.MapGet("/api/v1/ack/{token}", async ( HttpContext context, string token, IAckTokenService ackTokenService, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var verification = ackTokenService.VerifyToken(token); if (!verification.IsValid) { return Results.BadRequest(new AckResponse { Success = false, Error = verification.FailureReason?.ToString() ?? "Invalid token" }); } try { var auditEntry = new NotifyAuditEntryDocument { TenantId = verification.Token!.TenantId, Actor = "ack-link", Action = $"delivery.{verification.Token.Action}", EntityId = verification.Token.DeliveryId, EntityType = "delivery", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(new AckResponse { Success = true, DeliveryId = verification.Token!.DeliveryId, Action = verification.Token.Action, ProcessedAt = timeProvider.GetUtcNow() }); }); app.MapPost("/api/v1/ack/{token}", async ( HttpContext context, string token, AckRequest? request, IAckTokenService ackTokenService, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var verification = ackTokenService.VerifyToken(token); if (!verification.IsValid) { return Results.BadRequest(new AckResponse { Success = false, Error = verification.FailureReason?.ToString() ?? "Invalid token" }); } try { var auditEntry = new NotifyAuditEntryDocument { TenantId = verification.Token!.TenantId, Actor = "ack-link", Action = $"delivery.{verification.Token.Action}", EntityId = verification.Token.DeliveryId, EntityType = "delivery", Timestamp = timeProvider.GetUtcNow(), Payload = JsonSerializer.SerializeToNode( new { comment = request?.Comment, metadata = request?.Metadata }) as JsonObject }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(new AckResponse { Success = true, DeliveryId = verification.Token!.DeliveryId, Action = verification.Token.Action, ProcessedAt = timeProvider.GetUtcNow() }); }); app.MapPost("/api/v2/notify/security/ack-tokens", ( HttpContext context, CreateAckTokenRequest request, IAckTokenService ackTokenService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } if (string.IsNullOrWhiteSpace(request.DeliveryId) || string.IsNullOrWhiteSpace(request.Action)) { return Results.BadRequest(Error("invalid_request", "deliveryId and action are required.", context)); } var expiration = request.ExpirationHours.HasValue ? TimeSpan.FromHours(request.ExpirationHours.Value) : (TimeSpan?)null; var token = ackTokenService.CreateToken( tenantId, request.DeliveryId, request.Action, expiration, request.Metadata); return Results.Ok(new CreateAckTokenResponse { Token = token.TokenString, AckUrl = ackTokenService.CreateAckUrl(token), ExpiresAt = token.ExpiresAt }); }); app.MapPost("/api/v2/notify/security/ack-tokens/verify", ( HttpContext context, VerifyAckTokenRequest request, IAckTokenService ackTokenService) => { if (string.IsNullOrWhiteSpace(request.Token)) { return Results.BadRequest(Error("invalid_request", "token is required.", context)); } var verification = ackTokenService.VerifyToken(request.Token); return Results.Ok(new VerifyAckTokenResponse { IsValid = verification.IsValid, DeliveryId = verification.Token?.DeliveryId, Action = verification.Token?.Action, ExpiresAt = verification.Token?.ExpiresAt, FailureReason = verification.FailureReason?.ToString() }); }); app.MapPost("/api/v2/notify/security/html/validate", ( HttpContext context, Contracts.ValidateHtmlRequest request, IHtmlSanitizer htmlSanitizer) => { if (string.IsNullOrWhiteSpace(request.Html)) { return Results.Ok(new Contracts.ValidateHtmlResponse { IsSafe = true, Issues = [] }); } var result = htmlSanitizer.Validate(request.Html); return Results.Ok(new Contracts.ValidateHtmlResponse { IsSafe = result.IsValid, Issues = result.Errors.Select(i => new Contracts.HtmlIssue { Type = i.Type.ToString(), Description = i.Message }).Concat(result.Warnings.Select(w => new Contracts.HtmlIssue { Type = "Warning", Description = w })).ToArray(), Stats = null }); }); app.MapPost("/api/v2/notify/security/html/sanitize", ( HttpContext context, Contracts.SanitizeHtmlRequest request, IHtmlSanitizer htmlSanitizer) => { if (string.IsNullOrWhiteSpace(request.Html)) { return Results.Ok(new Contracts.SanitizeHtmlResponse { SanitizedHtml = string.Empty, WasModified = false }); } var profile = new SanitizationProfile { Name = "api-request", AllowDataUrls = request.AllowDataUrls, AllowedTags = request.AdditionalAllowedTags?.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? SanitizationProfile.Basic.AllowedTags, AllowedAttributes = SanitizationProfile.Basic.AllowedAttributes, AllowedUrlSchemes = SanitizationProfile.Basic.AllowedUrlSchemes, MaxContentLength = SanitizationProfile.Basic.MaxContentLength, MaxNestingDepth = SanitizationProfile.Basic.MaxNestingDepth, StripComments = SanitizationProfile.Basic.StripComments, StripScripts = SanitizationProfile.Basic.StripScripts }; var sanitized = htmlSanitizer.Sanitize(request.Html, profile); return Results.Ok(new Contracts.SanitizeHtmlResponse { SanitizedHtml = sanitized, WasModified = !string.Equals(request.Html, sanitized, StringComparison.Ordinal) }); }); app.MapPost("/api/v2/notify/security/webhook/{channelId}/rotate", async ( HttpContext context, string channelId, IWebhookSecurityService webhookSecurityService, INotifyAuditRepository auditRepository, TimeProvider timeProvider) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); if (string.IsNullOrWhiteSpace(actor)) actor = "api"; var result = await webhookSecurityService.RotateSecretAsync(tenantId, channelId, context.RequestAborted) .ConfigureAwait(false); try { var auditEntry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = "webhook.secret.rotated", EntityId = channelId, EntityType = "channel", Timestamp = timeProvider.GetUtcNow() }; await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); } catch { } return Results.Ok(new RotateWebhookSecretResponse { Success = result.Success, NewSecret = result.NewSecret, ActiveAt = result.ActiveAt, OldSecretExpiresAt = result.OldSecretExpiresAt, Error = result.Error }); }); app.MapGet("/api/v2/notify/security/webhook/{channelId}/secret", ( HttpContext context, string channelId, IWebhookSecurityService webhookSecurityService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var maskedSecret = webhookSecurityService.GetMaskedSecret(tenantId, channelId); return Results.Ok(new { channelId, maskedSecret }); }); app.MapGet("/api/v2/notify/security/isolation/violations", async ( HttpContext context, ITenantIsolationValidator isolationValidator, int? limit) => { var violations = await isolationValidator.GetViolationsAsync( tenantId: null, since: null, cancellationToken: context.RequestAborted).ConfigureAwait(false); var items = violations .Take(limit.GetValueOrDefault(100)) .ToList(); return Results.Ok(new { items, count = items.Count }); }); // ============================================= // Dead-Letter API (NOTIFY-SVC-40-004) // ============================================= app.MapPost("/api/v2/notify/dead-letter", async ( HttpContext context, EnqueueDeadLetterRequest request, IDeadLetterService deadLetterService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var enqueueRequest = new DeadLetterEnqueueRequest { TenantId = tenantId, DeliveryId = request.DeliveryId, EventId = request.EventId, ChannelId = request.ChannelId, ChannelType = request.ChannelType, FailureReason = request.FailureReason, FailureDetails = request.FailureDetails, AttemptCount = request.AttemptCount, LastAttemptAt = request.LastAttemptAt, Metadata = request.Metadata, OriginalPayload = request.OriginalPayload }; var entry = await deadLetterService.EnqueueAsync(enqueueRequest, context.RequestAborted).ConfigureAwait(false); return Results.Created($"/api/v2/notify/dead-letter/{entry.EntryId}", new DeadLetterEntryResponse { EntryId = entry.EntryId, TenantId = entry.TenantId, DeliveryId = entry.DeliveryId, EventId = entry.EventId, ChannelId = entry.ChannelId, ChannelType = entry.ChannelType, FailureReason = entry.FailureReason, FailureDetails = entry.FailureDetails, AttemptCount = entry.AttemptCount, CreatedAt = entry.CreatedAt, LastAttemptAt = entry.LastAttemptAt, Status = entry.Status.ToString(), RetryCount = entry.RetryCount, LastRetryAt = entry.LastRetryAt, Resolution = entry.Resolution, ResolvedBy = entry.ResolvedBy, ResolvedAt = entry.ResolvedAt }); }); app.MapGet("/api/v2/notify/dead-letter", async ( HttpContext context, IDeadLetterService deadLetterService, string? status, string? channelId, string? channelType, DateTimeOffset? since, DateTimeOffset? until, int? limit, int? offset) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var options = new DeadLetterListOptions { Status = Enum.TryParse(status, true, out var s) ? s : null, ChannelId = channelId, ChannelType = channelType, Since = since, Until = until, Limit = limit ?? 50, Offset = offset ?? 0 }; var entries = await deadLetterService.ListAsync(tenantId, options, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new ListDeadLetterResponse { Entries = entries.Select(e => new DeadLetterEntryResponse { EntryId = e.EntryId, TenantId = e.TenantId, DeliveryId = e.DeliveryId, EventId = e.EventId, ChannelId = e.ChannelId, ChannelType = e.ChannelType, FailureReason = e.FailureReason, FailureDetails = e.FailureDetails, AttemptCount = e.AttemptCount, CreatedAt = e.CreatedAt, LastAttemptAt = e.LastAttemptAt, Status = e.Status.ToString(), RetryCount = e.RetryCount, LastRetryAt = e.LastRetryAt, Resolution = e.Resolution, ResolvedBy = e.ResolvedBy, ResolvedAt = e.ResolvedAt }).ToList(), TotalCount = entries.Count }); }); app.MapGet("/api/v2/notify/dead-letter/{entryId}", async ( HttpContext context, string entryId, IDeadLetterService deadLetterService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var entry = await deadLetterService.GetAsync(tenantId, entryId, context.RequestAborted).ConfigureAwait(false); if (entry is null) { return Results.NotFound(Error("entry_not_found", $"Dead-letter entry {entryId} not found.", context)); } return Results.Ok(new DeadLetterEntryResponse { EntryId = entry.EntryId, TenantId = entry.TenantId, DeliveryId = entry.DeliveryId, EventId = entry.EventId, ChannelId = entry.ChannelId, ChannelType = entry.ChannelType, FailureReason = entry.FailureReason, FailureDetails = entry.FailureDetails, AttemptCount = entry.AttemptCount, CreatedAt = entry.CreatedAt, LastAttemptAt = entry.LastAttemptAt, Status = entry.Status.ToString(), RetryCount = entry.RetryCount, LastRetryAt = entry.LastRetryAt, Resolution = entry.Resolution, ResolvedBy = entry.ResolvedBy, ResolvedAt = entry.ResolvedAt }); }); app.MapPost("/api/v2/notify/dead-letter/retry", async ( HttpContext context, Contracts.RetryDeadLetterRequest request, IDeadLetterService deadLetterService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var results = await deadLetterService.RetryBatchAsync(tenantId, request.EntryIds, context.RequestAborted) .ConfigureAwait(false); return Results.Ok(new Contracts.RetryDeadLetterResponse { Results = results.Select(r => new Contracts.DeadLetterRetryResultItem { EntryId = r.EntryId, Success = r.Success, Error = r.Error, RetriedAt = r.RetriedAt, NewDeliveryId = r.NewDeliveryId }).ToList(), SuccessCount = results.Count(r => r.Success), FailureCount = results.Count(r => !r.Success) }); }); app.MapPost("/api/v2/notify/dead-letter/{entryId}/resolve", async ( HttpContext context, string entryId, ResolveDeadLetterRequest request, IDeadLetterService deadLetterService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } await deadLetterService.ResolveAsync(tenantId, entryId, request.Resolution, request.ResolvedBy, context.RequestAborted) .ConfigureAwait(false); return Results.NoContent(); }); app.MapGet("/api/v2/notify/dead-letter/stats", async ( HttpContext context, IDeadLetterService deadLetterService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var stats = await deadLetterService.GetStatsAsync(tenantId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new DeadLetterStatsResponse { TotalCount = stats.TotalCount, PendingCount = stats.PendingCount, RetryingCount = stats.RetryingCount, RetriedCount = stats.RetriedCount, ResolvedCount = stats.ResolvedCount, ExhaustedCount = stats.ExhaustedCount, ByChannel = stats.ByChannel, ByReason = stats.ByReason, OldestEntryAt = stats.OldestEntryAt, NewestEntryAt = stats.NewestEntryAt }); }); app.MapPost("/api/v2/notify/dead-letter/purge", async ( HttpContext context, PurgeDeadLetterRequest request, IDeadLetterService deadLetterService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var maxAge = TimeSpan.FromDays(request.MaxAgeDays); var purgedCount = await deadLetterService.PurgeExpiredAsync(tenantId, maxAge, context.RequestAborted) .ConfigureAwait(false); return Results.Ok(new PurgeDeadLetterResponse { PurgedCount = purgedCount }); }); // ============================================= // Retention Policy API (NOTIFY-SVC-40-004) // ============================================= app.MapGet("/api/v2/notify/retention/policy", async ( HttpContext context, IRetentionPolicyService retentionService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var policy = await retentionService.GetPolicyAsync(tenantId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new RetentionPolicyResponse { TenantId = tenantId, Policy = new RetentionPolicyDto { DeliveryRetentionDays = (int)policy.DeliveryRetention.TotalDays, AuditRetentionDays = (int)policy.AuditRetention.TotalDays, DeadLetterRetentionDays = (int)policy.DeadLetterRetention.TotalDays, StormDataRetentionDays = (int)policy.StormDataRetention.TotalDays, InboxRetentionDays = (int)policy.InboxRetention.TotalDays, EventHistoryRetentionDays = (int)policy.EventHistoryRetention.TotalDays, AutoCleanupEnabled = policy.AutoCleanupEnabled, CleanupSchedule = policy.CleanupSchedule, MaxDeletesPerRun = policy.MaxDeletesPerRun, ExtendResolvedRetention = policy.ExtendResolvedRetention, ResolvedRetentionMultiplier = policy.ResolvedRetentionMultiplier } }); }); app.MapPut("/api/v2/notify/retention/policy", async ( HttpContext context, UpdateRetentionPolicyRequest request, IRetentionPolicyService retentionService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var policy = new RetentionPolicy { DeliveryRetention = TimeSpan.FromDays(request.Policy.DeliveryRetentionDays), AuditRetention = TimeSpan.FromDays(request.Policy.AuditRetentionDays), DeadLetterRetention = TimeSpan.FromDays(request.Policy.DeadLetterRetentionDays), StormDataRetention = TimeSpan.FromDays(request.Policy.StormDataRetentionDays), InboxRetention = TimeSpan.FromDays(request.Policy.InboxRetentionDays), EventHistoryRetention = TimeSpan.FromDays(request.Policy.EventHistoryRetentionDays), AutoCleanupEnabled = request.Policy.AutoCleanupEnabled, CleanupSchedule = request.Policy.CleanupSchedule, MaxDeletesPerRun = request.Policy.MaxDeletesPerRun, ExtendResolvedRetention = request.Policy.ExtendResolvedRetention, ResolvedRetentionMultiplier = request.Policy.ResolvedRetentionMultiplier }; await retentionService.SetPolicyAsync(tenantId, policy, context.RequestAborted).ConfigureAwait(false); return Results.NoContent(); }); app.MapPost("/api/v2/notify/retention/cleanup", async ( HttpContext context, IRetentionPolicyService retentionService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var result = await retentionService.ExecuteCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new RetentionCleanupResponse { TenantId = result.TenantId, Success = result.Success, Error = result.Error, ExecutedAt = result.ExecutedAt, DurationMs = result.Duration.TotalMilliseconds, Counts = new RetentionCleanupCountsDto { Deliveries = result.Counts.Deliveries, AuditEntries = result.Counts.AuditEntries, DeadLetterEntries = result.Counts.DeadLetterEntries, StormData = result.Counts.StormData, InboxMessages = result.Counts.InboxMessages, Events = result.Counts.Events, Total = result.Counts.Total } }); }); app.MapGet("/api/v2/notify/retention/cleanup/preview", async ( HttpContext context, IRetentionPolicyService retentionService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var preview = await retentionService.PreviewCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false); return Results.Ok(new RetentionCleanupPreviewResponse { TenantId = preview.TenantId, PreviewedAt = preview.PreviewedAt, EstimatedCounts = new RetentionCleanupCountsDto { Deliveries = preview.EstimatedCounts.Deliveries, AuditEntries = preview.EstimatedCounts.AuditEntries, DeadLetterEntries = preview.EstimatedCounts.DeadLetterEntries, StormData = preview.EstimatedCounts.StormData, InboxMessages = preview.EstimatedCounts.InboxMessages, Events = preview.EstimatedCounts.Events, Total = preview.EstimatedCounts.Total }, PolicyApplied = new RetentionPolicyDto { DeliveryRetentionDays = (int)preview.PolicyApplied.DeliveryRetention.TotalDays, AuditRetentionDays = (int)preview.PolicyApplied.AuditRetention.TotalDays, DeadLetterRetentionDays = (int)preview.PolicyApplied.DeadLetterRetention.TotalDays, StormDataRetentionDays = (int)preview.PolicyApplied.StormDataRetention.TotalDays, InboxRetentionDays = (int)preview.PolicyApplied.InboxRetention.TotalDays, EventHistoryRetentionDays = (int)preview.PolicyApplied.EventHistoryRetention.TotalDays, AutoCleanupEnabled = preview.PolicyApplied.AutoCleanupEnabled, CleanupSchedule = preview.PolicyApplied.CleanupSchedule, MaxDeletesPerRun = preview.PolicyApplied.MaxDeletesPerRun, ExtendResolvedRetention = preview.PolicyApplied.ExtendResolvedRetention, ResolvedRetentionMultiplier = preview.PolicyApplied.ResolvedRetentionMultiplier }, CutoffDates = preview.CutoffDates }); }); app.MapGet("/api/v2/notify/retention/cleanup/last", async ( HttpContext context, IRetentionPolicyService retentionService) => { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); if (string.IsNullOrWhiteSpace(tenantId)) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var execution = await retentionService.GetLastExecutionAsync(tenantId, context.RequestAborted).ConfigureAwait(false); if (execution is null) { return Results.NotFound(Error("no_execution", "No cleanup execution found.", context)); } return Results.Ok(new RetentionCleanupExecutionResponse { ExecutionId = execution.ExecutionId, TenantId = execution.TenantId, StartedAt = execution.StartedAt, CompletedAt = execution.CompletedAt, Status = execution.Status.ToString(), Counts = execution.Counts is not null ? new RetentionCleanupCountsDto { Deliveries = execution.Counts.Deliveries, AuditEntries = execution.Counts.AuditEntries, DeadLetterEntries = execution.Counts.DeadLetterEntries, StormData = execution.Counts.StormData, InboxMessages = execution.Counts.InboxMessages, Events = execution.Counts.Events, Total = execution.Counts.Total } : null, Error = execution.Error }); }); // v2 REST APIs (/api/v2/notify/... for existing consumers) app.MapNotifyApiV2(); // v2 REST APIs (/api/v2/... simplified paths) app.MapRuleEndpoints(); app.MapTemplateEndpoints(); app.MapIncidentEndpoints(); app.MapIncidentLiveFeed(); app.MapSimulationEndpoints(); app.MapQuietHoursEndpoints(); app.MapThrottleEndpoints(); app.MapOperatorOverrideEndpoints(); app.MapEscalationEndpoints(); app.MapStormBreakerEndpoints(); app.MapLocalizationEndpoints(); app.MapFallbackEndpoints(); app.MapSecurityEndpoints(); app.MapObservabilityEndpoints(); app.MapGet("/.well-known/openapi", (HttpContext context) => { context.Response.Headers["X-OpenAPI-Scope"] = "notify"; context.Response.Headers.ETag = "\"notifier-oas-stub\""; const string stub = """ # notifier openapi stub openapi: 3.1.0 info: title: StellaOps Notifier paths: /api/v1/notify/quiet-hours: {} /api/v1/notify/incidents: {} /api/v1/ack/{token}: {} /api/v2/notify/templates: {} /api/v2/notify/rules: {} /api/v2/notify/channels: {} /api/v2/notify/deliveries: {} /api/v2/notify/simulate: {} /api/v2/notify/simulate/event: {} /api/v2/notify/quiet-hours: {} /api/v2/notify/maintenance-windows: {} /api/v2/notify/throttle-configs: {} /api/v2/notify/overrides: {} /api/v2/notify/escalation-policies: {} /api/v2/notify/oncall-schedules: {} /api/v2/notify/inbox: {} /api/v2/notify/localization/bundles: {} /api/v2/notify/localization/locales: {} /api/v2/notify/localization/resolve: {} /api/v2/notify/storms: {} /api/v2/notify/security/ack-tokens: {} /api/v2/notify/security/ack-tokens/verify: {} /api/v2/notify/security/html/validate: {} /api/v2/notify/security/html/sanitize: {} /api/v2/notify/security/webhook/{channelId}/rotate: {} /api/v2/notify/security/webhook/{channelId}/secret: {} /api/v2/notify/security/isolation/violations: {} /api/v2/notify/dead-letter: {} /api/v2/notify/dead-letter/{entryId}: {} /api/v2/notify/dead-letter/retry: {} /api/v2/notify/dead-letter/{entryId}/resolve: {} /api/v2/notify/dead-letter/stats: {} /api/v2/notify/dead-letter/purge: {} /api/v2/notify/retention/policy: {} /api/v2/notify/retention/cleanup: {} /api/v2/notify/retention/cleanup/preview: {} /api/v2/notify/retention/cleanup/last: {} /api/v2/rules: {} /api/v2/templates: {} /api/v2/incidents: {} /api/v2/incidents/live: {} """; return Results.Text(stub, "application/yaml", Encoding.UTF8); }); static object Error(string code, string message, HttpContext context) => new { error = new { code, message, traceId = context.TraceIdentifier } }; // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerOptions); app.Run(); // Make Program class accessible to test projects using WebApplicationFactory public partial class Program;