using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Notifier.WebService.Contracts; using StellaOps.Notifier.Worker.Dispatch; using StellaOps.Notifier.Worker.Templates; using StellaOps.Notify.Models; using StellaOps.Notifier.Worker.Storage; using StellaOps.Notifier.WebService.Extensions; namespace StellaOps.Notifier.WebService.Endpoints; /// /// API endpoints for rules, templates, and incidents management. /// public static class NotifyApiEndpoints { /// /// Maps all Notify API v2 endpoints. /// public static IEndpointRouteBuilder MapNotifyApiV2(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v2/notify") .WithTags("Notify") .WithOpenApi(); // Rules CRUD MapRulesEndpoints(group); // Templates CRUD + Preview MapTemplatesEndpoints(group); // Incidents MapIncidentsEndpoints(group); return app; } private static void MapRulesEndpoints(RouteGroupBuilder group) { group.MapGet("/rules", async ( HttpContext context, INotifyRuleRepository ruleRepository, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var rules = await ruleRepository.ListAsync(tenantId, cancellationToken); var response = rules.Select(MapRuleToResponse).ToList(); return Results.Ok(response); }); group.MapGet("/rules/{ruleId}", async ( HttpContext context, string ruleId, INotifyRuleRepository ruleRepository, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var rule = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken); if (rule is null) { return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context)); } return Results.Ok(MapRuleToResponse(rule)); }); group.MapPost("/rules", async ( HttpContext context, RuleCreateRequest request, INotifyRuleRepository ruleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = GetActor(context); var now = timeProvider.GetUtcNow(); var rule = MapRequestToRule(request, tenantId, actor, now); await ruleRepository.UpsertAsync(rule, cancellationToken); await AuditAsync(auditRepository, tenantId, "rule.created", actor, new Dictionary { ["ruleId"] = rule.RuleId, ["name"] = rule.Name }, cancellationToken); return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule)); }); group.MapPut("/rules/{ruleId}", async ( HttpContext context, string ruleId, RuleUpdateRequest request, INotifyRuleRepository ruleRepository, INotifyAuditRepository auditRepository, TimeProvider timeProvider, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken); if (existing is null) { return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context)); } var actor = GetActor(context); var now = timeProvider.GetUtcNow(); var updated = ApplyRuleUpdate(existing, request, actor, now); await ruleRepository.UpsertAsync(updated, cancellationToken); await AuditAsync(auditRepository, tenantId, "rule.updated", actor, new Dictionary { ["ruleId"] = updated.RuleId, ["name"] = updated.Name }, cancellationToken); return Results.Ok(MapRuleToResponse(updated)); }); group.MapDelete("/rules/{ruleId}", async ( HttpContext context, string ruleId, INotifyRuleRepository ruleRepository, INotifyAuditRepository auditRepository, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken); if (existing is null) { return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context)); } var actor = GetActor(context); await ruleRepository.DeleteAsync(tenantId, ruleId, cancellationToken); await AuditAsync(auditRepository, tenantId, "rule.deleted", actor, new Dictionary { ["ruleId"] = ruleId }, cancellationToken); return Results.NoContent(); }); } private static void MapTemplatesEndpoints(RouteGroupBuilder group) { group.MapGet("/templates", async ( HttpContext context, string? keyPrefix, string? channelType, string? locale, int? limit, INotifyTemplateService templateService, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } NotifyChannelType? channelTypeEnum = null; if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse(channelType, true, out var parsed)) { channelTypeEnum = parsed; } var templates = await templateService.ListAsync(tenantId, new TemplateListOptions { KeyPrefix = keyPrefix, ChannelType = channelTypeEnum, Locale = locale, Limit = limit }, cancellationToken); var response = templates.Select(MapTemplateToResponse).ToList(); return Results.Ok(response); }); group.MapGet("/templates/{templateId}", async ( HttpContext context, string templateId, INotifyTemplateService templateService, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var template = await templateService.GetByIdAsync(tenantId, templateId, cancellationToken); if (template is null) { return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context)); } return Results.Ok(MapTemplateToResponse(template)); }); group.MapPost("/templates", async ( HttpContext context, TemplateCreateRequest request, INotifyTemplateService templateService, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = GetActor(context); if (!Enum.TryParse(request.ChannelType, true, out var channelType)) { return Results.BadRequest(Error("invalid_channel_type", $"Invalid channel type: {request.ChannelType}", context)); } var renderMode = NotifyTemplateRenderMode.Markdown; if (!string.IsNullOrWhiteSpace(request.RenderMode) && Enum.TryParse(request.RenderMode, true, out var parsedMode)) { renderMode = parsedMode; } var format = NotifyDeliveryFormat.Json; if (!string.IsNullOrWhiteSpace(request.Format) && Enum.TryParse(request.Format, true, out var parsedFormat)) { format = parsedFormat; } var template = NotifyTemplate.Create( templateId: request.TemplateId, tenantId: tenantId, channelType: channelType, key: request.Key, locale: request.Locale, body: request.Body, renderMode: renderMode, format: format, description: request.Description, metadata: request.Metadata); var result = await templateService.UpsertAsync(template, actor, cancellationToken); if (!result.Success) { return Results.BadRequest(Error("template_validation_failed", result.Error ?? "Validation failed.", context)); } var created = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken); return result.IsNew ? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!)) : Results.Ok(MapTemplateToResponse(created!)); }); group.MapDelete("/templates/{templateId}", async ( HttpContext context, string templateId, INotifyTemplateService templateService, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = GetActor(context); var deleted = await templateService.DeleteAsync(tenantId, templateId, actor, cancellationToken); if (!deleted) { return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context)); } return Results.NoContent(); }); group.MapPost("/templates/preview", async ( HttpContext context, TemplatePreviewRequest request, INotifyTemplateService templateService, INotifyTemplateRenderer templateRenderer, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } NotifyTemplate? template = null; List? warnings = null; if (!string.IsNullOrWhiteSpace(request.TemplateId)) { template = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken); if (template is null) { return Results.NotFound(Error("template_not_found", $"Template {request.TemplateId} not found.", context)); } } else if (!string.IsNullOrWhiteSpace(request.TemplateBody)) { var validation = templateService.Validate(request.TemplateBody); if (!validation.IsValid) { return Results.BadRequest(Error("template_invalid", string.Join("; ", validation.Errors), context)); } warnings = validation.Warnings.ToList(); var format = NotifyDeliveryFormat.PlainText; if (!string.IsNullOrWhiteSpace(request.OutputFormat) && Enum.TryParse(request.OutputFormat, true, out var parsedFormat)) { format = parsedFormat; } template = NotifyTemplate.Create( templateId: "preview", tenantId: tenantId, channelType: NotifyChannelType.Custom, key: "preview", locale: "en-us", body: request.TemplateBody, format: format); } else { return Results.BadRequest(Error("template_required", "Either templateId or templateBody must be provided.", context)); } var sampleEvent = NotifyEvent.Create( eventId: Guid.NewGuid(), kind: request.EventKind ?? "preview.event", tenant: tenantId, ts: DateTimeOffset.UtcNow, payload: request.SamplePayload ?? new JsonObject(), attributes: request.SampleAttributes ?? new Dictionary(), actor: "preview", version: "1"); var rendered = await templateRenderer.RenderAsync(template, sampleEvent, cancellationToken); return Results.Ok(new TemplatePreviewResponse { RenderedBody = rendered.Body, RenderedSubject = rendered.Subject, BodyHash = rendered.BodyHash, Format = rendered.Format.ToString(), Warnings = warnings }); }); group.MapPost("/templates/validate", ( HttpContext context, TemplatePreviewRequest request, INotifyTemplateService templateService) => { if (string.IsNullOrWhiteSpace(request.TemplateBody)) { return Results.BadRequest(Error("template_body_required", "templateBody is required.", context)); } var result = templateService.Validate(request.TemplateBody); return Results.Ok(new { isValid = result.IsValid, errors = result.Errors, warnings = result.Warnings }); }); } private static void MapIncidentsEndpoints(RouteGroupBuilder group) { group.MapGet("/incidents", async ( HttpContext context, string? status, string? eventKindPrefix, DateTimeOffset? since, DateTimeOffset? until, int? limit, string? cursor, INotifyDeliveryRepository deliveryRepository, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } // For now, return recent deliveries grouped by event kind as "incidents" // Full incident correlation will be implemented in NOTIFY-SVC-39-001 var queryResult = await deliveryRepository.QueryAsync(tenantId, since, status, limit ?? 100, cursor, cancellationToken); var deliveries = queryResult.Items; var incidents = deliveries .GroupBy(d => d.EventId) .Select(g => new IncidentResponse { IncidentId = g.Key.ToString(), TenantId = tenantId, EventKind = g.First().Kind, Status = g.All(d => d.Status == NotifyDeliveryStatus.Delivered) ? "resolved" : "open", Severity = "medium", Title = $"Notification: {g.First().Kind}", Description = null, EventCount = g.Count(), FirstOccurrence = g.Min(d => d.CreatedAt), LastOccurrence = g.Max(d => d.CreatedAt), Labels = null, Metadata = null }) .ToList(); return Results.Ok(new IncidentListResponse { Incidents = incidents, TotalCount = incidents.Count, NextCursor = queryResult.ContinuationToken }); }); group.MapPost("/incidents/{incidentId}/ack", async ( HttpContext context, string incidentId, IncidentAckRequest request, INotifyAuditRepository auditRepository, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = request.Actor ?? GetActor(context); await AuditAsync(auditRepository, tenantId, "incident.acknowledged", actor, new Dictionary { ["incidentId"] = incidentId, ["comment"] = request.Comment ?? "" }, cancellationToken); return Results.NoContent(); }); group.MapPost("/incidents/{incidentId}/resolve", async ( HttpContext context, string incidentId, IncidentResolveRequest request, INotifyAuditRepository auditRepository, CancellationToken cancellationToken) => { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var actor = request.Actor ?? GetActor(context); await AuditAsync(auditRepository, tenantId, "incident.resolved", actor, new Dictionary { ["incidentId"] = incidentId, ["reason"] = request.Reason ?? "", ["comment"] = request.Comment ?? "" }, cancellationToken); return Results.NoContent(); }); } #region Helpers private static string? GetTenantId(HttpContext context) { var value = context.Request.Headers["X-StellaOps-Tenant"].ToString(); return string.IsNullOrWhiteSpace(value) ? null : value; } private static string GetActor(HttpContext context) { return context.Request.Headers["X-StellaOps-Actor"].ToString() is { Length: > 0 } actor ? actor : "api"; } private static object Error(string code, string message, HttpContext context) => new { error = new { code, message, traceId = context.TraceIdentifier } }; private static async Task AuditAsync( INotifyAuditRepository repository, string tenantId, string action, string actor, Dictionary metadata, CancellationToken cancellationToken) { try { await repository.AppendAsync(tenantId, action, actor, metadata, cancellationToken); } catch { // Ignore audit failures } } #endregion #region Mappers private static RuleResponse MapRuleToResponse(NotifyRule rule) { return new RuleResponse { RuleId = rule.RuleId, TenantId = rule.TenantId, Name = rule.Name, Description = rule.Description, Enabled = rule.Enabled, Match = new RuleMatchResponse { EventKinds = rule.Match.EventKinds.ToList(), Namespaces = rule.Match.Namespaces.ToList(), Repositories = rule.Match.Repositories.ToList(), Digests = rule.Match.Digests.ToList(), Labels = rule.Match.Labels.ToList(), ComponentPurls = rule.Match.ComponentPurls.ToList(), MinSeverity = rule.Match.MinSeverity, Verdicts = rule.Match.Verdicts.ToList(), KevOnly = rule.Match.KevOnly ?? false }, Actions = rule.Actions.Select(a => new RuleActionResponse { ActionId = a.ActionId, Channel = a.Channel, Template = a.Template, Digest = a.Digest, Throttle = a.Throttle?.ToString(), Locale = a.Locale, Enabled = a.Enabled, Metadata = a.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value) }).ToList(), Labels = rule.Labels.ToDictionary(kv => kv.Key, kv => kv.Value), Metadata = rule.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value), CreatedBy = rule.CreatedBy, CreatedAt = rule.CreatedAt, UpdatedBy = rule.UpdatedBy, UpdatedAt = rule.UpdatedAt }; } private static NotifyRule MapRequestToRule( RuleCreateRequest request, string tenantId, string actor, DateTimeOffset now) { var match = NotifyRuleMatch.Create( eventKinds: request.Match.EventKinds, namespaces: request.Match.Namespaces, repositories: request.Match.Repositories, digests: request.Match.Digests, labels: request.Match.Labels, componentPurls: request.Match.ComponentPurls, minSeverity: request.Match.MinSeverity, verdicts: request.Match.Verdicts, kevOnly: request.Match.KevOnly); var actions = request.Actions.Select(a => NotifyRuleAction.Create( actionId: a.ActionId, channel: a.Channel, template: a.Template, digest: a.Digest, throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle), locale: a.Locale, enabled: a.Enabled, metadata: a.Metadata)); return NotifyRule.Create( ruleId: request.RuleId, tenantId: tenantId, name: request.Name, match: match, actions: actions, enabled: request.Enabled, description: request.Description, labels: request.Labels, metadata: request.Metadata, createdBy: actor, createdAt: now, updatedBy: actor, updatedAt: now); } private static NotifyRule ApplyRuleUpdate( NotifyRule existing, RuleUpdateRequest request, string actor, DateTimeOffset now) { var match = request.Match is not null ? NotifyRuleMatch.Create( eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.ToList(), namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.ToList(), repositories: request.Match.Repositories ?? existing.Match.Repositories.ToList(), digests: request.Match.Digests ?? existing.Match.Digests.ToList(), labels: request.Match.Labels ?? existing.Match.Labels.ToList(), componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.ToList(), minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity, verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.ToList(), kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly) : existing.Match; var actions = request.Actions is not null ? request.Actions.Select(a => NotifyRuleAction.Create( actionId: a.ActionId, channel: a.Channel, template: a.Template, digest: a.Digest, throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle), locale: a.Locale, enabled: a.Enabled, metadata: a.Metadata)) : existing.Actions; return NotifyRule.Create( ruleId: existing.RuleId, tenantId: existing.TenantId, name: request.Name ?? existing.Name, match: match, actions: actions, enabled: request.Enabled ?? existing.Enabled, description: request.Description ?? existing.Description, labels: request.Labels ?? existing.Labels.ToDictionary(kv => kv.Key, kv => kv.Value), metadata: request.Metadata ?? existing.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value), createdBy: existing.CreatedBy, createdAt: existing.CreatedAt, updatedBy: actor, updatedAt: now); } private static TemplateResponse MapTemplateToResponse(NotifyTemplate template) { return new TemplateResponse { TemplateId = template.TemplateId, TenantId = template.TenantId, Key = template.Key, ChannelType = template.ChannelType.ToString(), Locale = template.Locale, Body = template.Body, RenderMode = template.RenderMode.ToString(), Format = template.Format.ToString(), Description = template.Description, Metadata = template.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value), CreatedBy = template.CreatedBy, CreatedAt = template.CreatedAt, UpdatedBy = template.UpdatedBy, UpdatedAt = template.UpdatedAt }; } #endregion }