using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Notify.Models; using StellaOps.Notify.Storage.Mongo.Documents; using StellaOps.Notify.Storage.Mongo.Repositories; using StellaOps.Notifier.WebService.Contracts; using StellaOps.Notifier.Worker.Dispatch; using StellaOps.Notifier.Worker.Templates; namespace StellaOps.Notifier.WebService.Endpoints; /// /// Maps template management endpoints. /// public static class TemplateEndpoints { public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v2/templates") .WithTags("Templates"); group.MapGet("/", ListTemplatesAsync) .WithName("ListTemplates") .WithSummary("Lists all templates for a tenant"); group.MapGet("/{templateId}", GetTemplateAsync) .WithName("GetTemplate") .WithSummary("Gets a template by ID"); group.MapPost("/", CreateTemplateAsync) .WithName("CreateTemplate") .WithSummary("Creates a new template"); group.MapPut("/{templateId}", UpdateTemplateAsync) .WithName("UpdateTemplate") .WithSummary("Updates an existing template"); group.MapDelete("/{templateId}", DeleteTemplateAsync) .WithName("DeleteTemplate") .WithSummary("Deletes a template"); group.MapPost("/preview", PreviewTemplateAsync) .WithName("PreviewTemplate") .WithSummary("Previews a template rendering"); return app; } private static async Task ListTemplatesAsync( HttpContext context, INotifyTemplateRepository templates, string? keyPrefix = null, string? channelType = null, string? locale = null, int? limit = null) { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var allTemplates = await templates.ListAsync(tenantId, context.RequestAborted); IEnumerable filtered = allTemplates; if (!string.IsNullOrWhiteSpace(keyPrefix)) { filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)); } if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse(channelType, true, out var ct)) { filtered = filtered.Where(t => t.ChannelType == ct); } if (!string.IsNullOrWhiteSpace(locale)) { filtered = filtered.Where(t => t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase)); } if (limit.HasValue && limit.Value > 0) { filtered = filtered.Take(limit.Value); } var response = filtered.Select(MapToResponse).ToList(); return Results.Ok(response); } private static async Task GetTemplateAsync( HttpContext context, string templateId, INotifyTemplateRepository templates) { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } var template = await templates.GetAsync(tenantId, templateId, context.RequestAborted); if (template is null) { return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context)); } return Results.Ok(MapToResponse(template)); } private static async Task CreateTemplateAsync( HttpContext context, TemplateCreateRequest request, INotifyTemplateRepository templates, INotifyTemplateService? templateService, INotifyAuditRepository audit, TimeProvider timeProvider) { 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); // Validate template body if (templateService is not null) { var validation = templateService.Validate(request.Body); if (!validation.IsValid) { return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context)); } } // Check if template already exists var existing = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted); if (existing is not null) { return Results.Conflict(Error("template_exists", $"Template '{request.TemplateId}' already exists.", context)); } var template = MapFromRequest(request, tenantId, actor, timeProvider); await templates.UpsertAsync(template, context.RequestAborted); await AppendAuditAsync(audit, tenantId, actor, "template.created", request.TemplateId, "template", request, timeProvider, context.RequestAborted); return Results.Created($"/api/v2/templates/{template.TemplateId}", MapToResponse(template)); } private static async Task UpdateTemplateAsync( HttpContext context, string templateId, TemplateCreateRequest request, INotifyTemplateRepository templates, INotifyTemplateService? templateService, INotifyAuditRepository audit, TimeProvider timeProvider) { 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); // Validate template body if (templateService is not null) { var validation = templateService.Validate(request.Body); if (!validation.IsValid) { return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context)); } } var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted); if (existing is null) { return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context)); } var updated = MapFromRequest(request with { TemplateId = templateId }, tenantId, actor, timeProvider, existing); await templates.UpsertAsync(updated, context.RequestAborted); await AppendAuditAsync(audit, tenantId, actor, "template.updated", templateId, "template", request, timeProvider, context.RequestAborted); return Results.Ok(MapToResponse(updated)); } private static async Task DeleteTemplateAsync( HttpContext context, string templateId, INotifyTemplateRepository templates, INotifyAuditRepository audit, TimeProvider timeProvider) { 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 existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted); if (existing is null) { return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context)); } await templates.DeleteAsync(tenantId, templateId, context.RequestAborted); await AppendAuditAsync(audit, tenantId, actor, "template.deleted", templateId, "template", new { templateId }, timeProvider, context.RequestAborted); return Results.NoContent(); } private static async Task PreviewTemplateAsync( HttpContext context, TemplatePreviewRequest request, INotifyTemplateRepository templates, INotifyTemplateRenderer renderer, INotifyTemplateService? templateService, TimeProvider timeProvider) { var tenantId = GetTenantId(context); if (tenantId is null) { return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); } NotifyTemplate? template = null; if (!string.IsNullOrWhiteSpace(request.TemplateId)) { template = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted); if (template is null) { return Results.NotFound(Error("template_not_found", $"Template '{request.TemplateId}' not found.", context)); } } else if (!string.IsNullOrWhiteSpace(request.TemplateBody)) { // Create a temporary template for preview var format = Enum.TryParse(request.OutputFormat, true, out var f) ? f : NotifyDeliveryFormat.PlainText; 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("invalid_request", "Either templateId or templateBody is required.", context)); } // Validate template body List? warnings = null; if (templateService is not null) { var validation = templateService.Validate(template.Body); if (!validation.IsValid) { return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context)); } warnings = validation.Warnings.ToList(); } // Create sample event var sampleEvent = NotifyEvent.Create( eventId: Guid.NewGuid(), kind: request.EventKind ?? "sample.event", tenant: tenantId, ts: timeProvider.GetUtcNow(), payload: request.SamplePayload ?? new JsonObject(), attributes: request.SampleAttributes ?? new Dictionary(), actor: "preview", version: "1"); var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted); return Results.Ok(new TemplatePreviewResponse { RenderedBody = rendered.Body, RenderedSubject = rendered.Subject, BodyHash = rendered.BodyHash, Format = rendered.Format.ToString(), Warnings = warnings?.Count > 0 ? warnings : null }); } private static NotifyTemplate MapFromRequest( TemplateCreateRequest request, string tenantId, string actor, TimeProvider timeProvider, NotifyTemplate? existing = null) { var now = timeProvider.GetUtcNow(); var channelType = Enum.TryParse(request.ChannelType, true, out var ct) ? ct : NotifyChannelType.Custom; var renderMode = Enum.TryParse(request.RenderMode, true, out var rm) ? rm : NotifyTemplateRenderMode.Markdown; var format = Enum.TryParse(request.Format, true, out var f) ? f : NotifyDeliveryFormat.PlainText; return 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, createdBy: existing?.CreatedBy ?? actor, createdAt: existing?.CreatedAt ?? now, updatedBy: actor, updatedAt: now); } private static TemplateResponse MapToResponse(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(kvp => kvp.Key, kvp => kvp.Value), CreatedBy = template.CreatedBy, CreatedAt = template.CreatedAt, UpdatedBy = template.UpdatedBy, UpdatedAt = template.UpdatedAt }; } private static string? GetTenantId(HttpContext context) { var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId; } private static string GetActor(HttpContext context) { var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); return string.IsNullOrWhiteSpace(actor) ? "api" : actor; } private static async Task AppendAuditAsync( INotifyAuditRepository audit, string tenantId, string actor, string action, string entityId, string entityType, object payload, TimeProvider timeProvider, CancellationToken cancellationToken) { try { var entry = new NotifyAuditEntryDocument { TenantId = tenantId, Actor = actor, Action = action, EntityId = entityId, EntityType = entityType, Timestamp = timeProvider.GetUtcNow(), Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize( JsonSerializer.Serialize(payload)) }; await audit.AppendAsync(entry, cancellationToken); } catch { // Ignore audit failures } } private static object Error(string code, string message, HttpContext context) => new { error = new { code, message, traceId = context.TraceIdentifier } }; }