3218 lines
115 KiB
C#
3218 lines
115 KiB
C#
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>(TimeProvider.System);
|
|
|
|
// Fallback no-op event queue for environments that do not configure a real backend.
|
|
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
|
|
|
// In-memory storage (document store removed)
|
|
builder.Services.AddSingleton<INotifyChannelRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<INotifyRuleRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<INotifyTemplateRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<INotifyDeliveryRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<INotifyAuditRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<INotifyLockRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
|
|
builder.Services.AddSingleton<INotifyInboxRepository, InMemoryInboxStore>();
|
|
builder.Services.AddSingleton<INotifyLocalizationRepository, InMemoryNotifyRepositories>();
|
|
builder.Services.AddSingleton<INotifyPackApprovalRepository, InMemoryPackApprovalRepository>();
|
|
builder.Services.AddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
|
|
builder.Services.AddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
|
|
builder.Services.AddSingleton<INotifyQuietHoursRepository, InMemoryQuietHoursRepository>();
|
|
builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, InMemoryMaintenanceWindowRepository>();
|
|
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
|
|
builder.Services.AddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallScheduleRepository>();
|
|
|
|
// 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<ILocalizationResolver, DefaultLocalizationResolver>();
|
|
|
|
// Security services (NOTIFY-SVC-40-003)
|
|
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
|
|
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
|
|
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
|
|
builder.Services.AddSingleton<IWebhookSecurityService, InMemoryWebhookSecurityService>();
|
|
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
|
|
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
|
|
builder.Services.AddSingleton<ITenantIsolationValidator, InMemoryTenantIsolationValidator>();
|
|
|
|
// Observability, dead-letter, and retention services (NOTIFY-SVC-40-004)
|
|
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
|
|
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
|
|
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
|
|
// 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<StellaRouterOptionsBase>();
|
|
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"] =
|
|
"<https://docs.stellaops.example.com/notify/deprecations>; 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<string, string>(),
|
|
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<string, string>(),
|
|
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<string, string>(),
|
|
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<StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine>.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<StellaOps.Notifier.Worker.Simulation.DefaultNotifySimulationEngine>.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<StellaOps.Notify.Models.NotifyOverrideType>(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<NotifyEscalationTargetType>(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<NotifyOnCallLayer>.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<NotifyContactMethodType>(cm.Type, ignoreCase: true, out var cmt) ? cmt : NotifyContactMethodType.Email,
|
|
cm.Address ?? string.Empty)))),
|
|
restrictions: l.Restrictions is null
|
|
? null
|
|
: NotifyOnCallRestriction.Create(
|
|
Enum.TryParse<NotifyRestrictionType>(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<DeadLetterStatus>(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;
|