Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
421 lines
15 KiB
C#
421 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Maps template management endpoints.
|
|
/// </summary>
|
|
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<IResult> 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<NotifyTemplate> filtered = allTemplates;
|
|
|
|
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
|
{
|
|
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse<NotifyChannelType>(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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<NotifyDeliveryFormat>(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<string>? 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<string, string>(),
|
|
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<NotifyChannelType>(request.ChannelType, true, out var ct)
|
|
? ct
|
|
: NotifyChannelType.Custom;
|
|
|
|
var renderMode = Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var rm)
|
|
? rm
|
|
: NotifyTemplateRenderMode.Markdown;
|
|
|
|
var format = Enum.TryParse<NotifyDeliveryFormat>(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<MongoDB.Bson.BsonDocument>(
|
|
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
|
|
}
|
|
};
|
|
}
|