up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,16 +1,16 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a channel.
/// </summary>
public sealed record ChannelUpsertRequest
{
public string? Name { get; init; }
public NotifyChannelType? Type { get; init; }
public string? Endpoint { get; init; }
public string? Target { get; init; }
public string? SecretRef { get; init; }
public string? Description { get; init; }
}
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request for creating or updating a channel.
/// </summary>
public sealed record ChannelUpsertRequest
{
public string? Name { get; init; }
public NotifyChannelType? Type { get; init; }
public string? Endpoint { get; init; }
public string? Target { get; init; }
public string? SecretRef { get; init; }
public string? Description { get; init; }
}

View File

@@ -1,137 +1,137 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
public sealed record EnqueueDeadLetterRequest
{
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Response for dead-letter entry operations.
/// </summary>
public sealed record DeadLetterEntryResponse
{
public required string EntryId { get; init; }
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public required int AttemptCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public required string Status { get; init; }
public int RetryCount { get; init; }
public DateTimeOffset? LastRetryAt { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
}
/// <summary>
/// Request to list dead-letter entries.
/// </summary>
public sealed record ListDeadLetterRequest
{
public string? Status { get; init; }
public string? ChannelId { get; init; }
public string? ChannelType { get; init; }
public DateTimeOffset? Since { get; init; }
public DateTimeOffset? Until { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
/// <summary>
/// Response for listing dead-letter entries.
/// </summary>
public sealed record ListDeadLetterResponse
{
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to retry dead-letter entries.
/// </summary>
public sealed record RetryDeadLetterRequest
{
public required IReadOnlyList<string> EntryIds { get; init; }
}
/// <summary>
/// Response for retry operations.
/// </summary>
public sealed record RetryDeadLetterResponse
{
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
}
/// <summary>
/// Individual retry result.
/// </summary>
public sealed record DeadLetterRetryResultItem
{
public required string EntryId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public DateTimeOffset? RetriedAt { get; init; }
public string? NewDeliveryId { get; init; }
}
/// <summary>
/// Request to resolve a dead-letter entry.
/// </summary>
public sealed record ResolveDeadLetterRequest
{
public required string Resolution { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Response for dead-letter statistics.
/// </summary>
public sealed record DeadLetterStatsResponse
{
public required int TotalCount { get; init; }
public required int PendingCount { get; init; }
public required int RetryingCount { get; init; }
public required int RetriedCount { get; init; }
public required int ResolvedCount { get; init; }
public required int ExhaustedCount { get; init; }
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}
/// <summary>
/// Request to purge expired entries.
/// </summary>
public sealed record PurgeDeadLetterRequest
{
public int MaxAgeDays { get; init; } = 30;
}
/// <summary>
/// Response for purge operation.
/// </summary>
public sealed record PurgeDeadLetterResponse
{
public required int PurgedCount { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to enqueue a dead-letter entry.
/// </summary>
public sealed record EnqueueDeadLetterRequest
{
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public int AttemptCount { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public string? OriginalPayload { get; init; }
}
/// <summary>
/// Response for dead-letter entry operations.
/// </summary>
public sealed record DeadLetterEntryResponse
{
public required string EntryId { get; init; }
public required string TenantId { get; init; }
public required string DeliveryId { get; init; }
public required string EventId { get; init; }
public required string ChannelId { get; init; }
public required string ChannelType { get; init; }
public required string FailureReason { get; init; }
public string? FailureDetails { get; init; }
public required int AttemptCount { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastAttemptAt { get; init; }
public required string Status { get; init; }
public int RetryCount { get; init; }
public DateTimeOffset? LastRetryAt { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
}
/// <summary>
/// Request to list dead-letter entries.
/// </summary>
public sealed record ListDeadLetterRequest
{
public string? Status { get; init; }
public string? ChannelId { get; init; }
public string? ChannelType { get; init; }
public DateTimeOffset? Since { get; init; }
public DateTimeOffset? Until { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
/// <summary>
/// Response for listing dead-letter entries.
/// </summary>
public sealed record ListDeadLetterResponse
{
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Request to retry dead-letter entries.
/// </summary>
public sealed record RetryDeadLetterRequest
{
public required IReadOnlyList<string> EntryIds { get; init; }
}
/// <summary>
/// Response for retry operations.
/// </summary>
public sealed record RetryDeadLetterResponse
{
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
}
/// <summary>
/// Individual retry result.
/// </summary>
public sealed record DeadLetterRetryResultItem
{
public required string EntryId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public DateTimeOffset? RetriedAt { get; init; }
public string? NewDeliveryId { get; init; }
}
/// <summary>
/// Request to resolve a dead-letter entry.
/// </summary>
public sealed record ResolveDeadLetterRequest
{
public required string Resolution { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Response for dead-letter statistics.
/// </summary>
public sealed record DeadLetterStatsResponse
{
public required int TotalCount { get; init; }
public required int PendingCount { get; init; }
public required int RetryingCount { get; init; }
public required int RetriedCount { get; init; }
public required int ResolvedCount { get; init; }
public required int ExhaustedCount { get; init; }
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
public DateTimeOffset? OldestEntryAt { get; init; }
public DateTimeOffset? NewestEntryAt { get; init; }
}
/// <summary>
/// Request to purge expired entries.
/// </summary>
public sealed record PurgeDeadLetterRequest
{
public int MaxAgeDays { get; init; } = 30;
}
/// <summary>
/// Response for purge operation.
/// </summary>
public sealed record PurgeDeadLetterResponse
{
public required int PurgedCount { get; init; }
}

View File

@@ -1,149 +1,149 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update an escalation policy.
/// </summary>
public sealed record EscalationPolicyUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
public int? RepeatCount { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Escalation level configuration.
/// </summary>
public sealed record EscalationLevelRequest
{
public int Order { get; init; }
public TimeSpan EscalateAfter { get; init; }
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
}
/// <summary>
/// Escalation target configuration.
/// </summary>
public sealed record EscalationTargetRequest
{
public string? Type { get; init; }
public string? TargetId { get; init; }
}
/// <summary>
/// Request to start an escalation for an incident.
/// </summary>
public sealed record StartEscalationRequest
{
public string? IncidentId { get; init; }
public string? PolicyId { get; init; }
}
/// <summary>
/// Request to acknowledge an escalation.
/// </summary>
public sealed record AcknowledgeEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
}
/// <summary>
/// Request to resolve an escalation.
/// </summary>
public sealed record ResolveEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Request to create/update an on-call schedule.
/// </summary>
public sealed record OnCallScheduleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? TimeZone { get; init; }
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// On-call layer configuration.
/// </summary>
public sealed record OnCallLayerRequest
{
public string? LayerId { get; init; }
public string? Name { get; init; }
public int Priority { get; init; }
public DateTimeOffset RotationStartsAt { get; init; }
public TimeSpan RotationInterval { get; init; }
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
public OnCallRestrictionRequest? Restrictions { get; init; }
}
/// <summary>
/// On-call participant configuration.
/// </summary>
public sealed record OnCallParticipantRequest
{
public string? UserId { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
}
/// <summary>
/// Contact method configuration.
/// </summary>
public sealed record ContactMethodRequest
{
public string? Type { get; init; }
public string? Address { get; init; }
}
/// <summary>
/// On-call restriction configuration.
/// </summary>
public sealed record OnCallRestrictionRequest
{
public string? Type { get; init; }
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
}
/// <summary>
/// Time range for on-call restrictions.
/// </summary>
public sealed record TimeRangeRequest
{
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public DayOfWeek? DayOfWeek { get; init; }
}
/// <summary>
/// Request to add an on-call override.
/// </summary>
public sealed record OnCallOverrideRequest
{
public string? UserId { get; init; }
public DateTimeOffset StartsAt { get; init; }
public DateTimeOffset EndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Request to resolve who is on-call.
/// </summary>
public sealed record OnCallResolveRequest
{
public DateTimeOffset? EvaluationTime { get; init; }
}
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update an escalation policy.
/// </summary>
public sealed record EscalationPolicyUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
public int? RepeatCount { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Escalation level configuration.
/// </summary>
public sealed record EscalationLevelRequest
{
public int Order { get; init; }
public TimeSpan EscalateAfter { get; init; }
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
}
/// <summary>
/// Escalation target configuration.
/// </summary>
public sealed record EscalationTargetRequest
{
public string? Type { get; init; }
public string? TargetId { get; init; }
}
/// <summary>
/// Request to start an escalation for an incident.
/// </summary>
public sealed record StartEscalationRequest
{
public string? IncidentId { get; init; }
public string? PolicyId { get; init; }
}
/// <summary>
/// Request to acknowledge an escalation.
/// </summary>
public sealed record AcknowledgeEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
}
/// <summary>
/// Request to resolve an escalation.
/// </summary>
public sealed record ResolveEscalationRequest
{
public string? StateIdOrIncidentId { get; init; }
public string? ResolvedBy { get; init; }
}
/// <summary>
/// Request to create/update an on-call schedule.
/// </summary>
public sealed record OnCallScheduleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? TimeZone { get; init; }
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
public bool? Enabled { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// On-call layer configuration.
/// </summary>
public sealed record OnCallLayerRequest
{
public string? LayerId { get; init; }
public string? Name { get; init; }
public int Priority { get; init; }
public DateTimeOffset RotationStartsAt { get; init; }
public TimeSpan RotationInterval { get; init; }
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
public OnCallRestrictionRequest? Restrictions { get; init; }
}
/// <summary>
/// On-call participant configuration.
/// </summary>
public sealed record OnCallParticipantRequest
{
public string? UserId { get; init; }
public string? Name { get; init; }
public string? Email { get; init; }
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
}
/// <summary>
/// Contact method configuration.
/// </summary>
public sealed record ContactMethodRequest
{
public string? Type { get; init; }
public string? Address { get; init; }
}
/// <summary>
/// On-call restriction configuration.
/// </summary>
public sealed record OnCallRestrictionRequest
{
public string? Type { get; init; }
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
}
/// <summary>
/// Time range for on-call restrictions.
/// </summary>
public sealed record TimeRangeRequest
{
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public DayOfWeek? DayOfWeek { get; init; }
}
/// <summary>
/// Request to add an on-call override.
/// </summary>
public sealed record OnCallOverrideRequest
{
public string? UserId { get; init; }
public DateTimeOffset StartsAt { get; init; }
public DateTimeOffset EndsAt { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Request to resolve who is on-call.
/// </summary>
public sealed record OnCallResolveRequest
{
public DateTimeOffset? EvaluationTime { get; init; }
}

View File

@@ -1,121 +1,121 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Incident list query parameters.
/// </summary>
public sealed record IncidentListQuery
{
/// <summary>
/// Filter by status (open, acknowledged, resolved).
/// </summary>
public string? Status { get; init; }
/// <summary>
/// Filter by event kind prefix.
/// </summary>
public string? EventKindPrefix { get; init; }
/// <summary>
/// Filter incidents after this timestamp.
/// </summary>
public DateTimeOffset? Since { get; init; }
/// <summary>
/// Filter incidents before this timestamp.
/// </summary>
public DateTimeOffset? Until { get; init; }
/// <summary>
/// Maximum number of results.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Cursor for pagination.
/// </summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Incident response DTO.
/// </summary>
public sealed record IncidentResponse
{
public required string IncidentId { get; init; }
public required string TenantId { get; init; }
public required string EventKind { get; init; }
public required string Status { get; init; }
public required string Severity { get; init; }
public required string Title { get; init; }
public string? Description { get; init; }
public required int EventCount { get; init; }
public required DateTimeOffset FirstOccurrence { get; init; }
public required DateTimeOffset LastOccurrence { get; init; }
public string? AcknowledgedBy { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public List<string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Incident list response with pagination.
/// </summary>
public sealed record IncidentListResponse
{
public required List<IncidentResponse> Incidents { get; init; }
public required int TotalCount { get; init; }
public string? NextCursor { get; init; }
}
/// <summary>
/// Request to acknowledge an incident.
/// </summary>
public sealed record IncidentAckRequest
{
/// <summary>
/// Actor performing the acknowledgement.
/// </summary>
public string? Actor { get; init; }
/// <summary>
/// Optional comment.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Request to resolve an incident.
/// </summary>
public sealed record IncidentResolveRequest
{
/// <summary>
/// Actor resolving the incident.
/// </summary>
public string? Actor { get; init; }
/// <summary>
/// Resolution reason.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Optional comment.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Delivery history item for an incident.
/// </summary>
public sealed record DeliveryHistoryItem
{
public required string DeliveryId { get; init; }
public required string ChannelType { get; init; }
public required string ChannelName { get; init; }
public required string Status { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? ErrorMessage { get; init; }
public int Attempts { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Incident list query parameters.
/// </summary>
public sealed record IncidentListQuery
{
/// <summary>
/// Filter by status (open, acknowledged, resolved).
/// </summary>
public string? Status { get; init; }
/// <summary>
/// Filter by event kind prefix.
/// </summary>
public string? EventKindPrefix { get; init; }
/// <summary>
/// Filter incidents after this timestamp.
/// </summary>
public DateTimeOffset? Since { get; init; }
/// <summary>
/// Filter incidents before this timestamp.
/// </summary>
public DateTimeOffset? Until { get; init; }
/// <summary>
/// Maximum number of results.
/// </summary>
public int? Limit { get; init; }
/// <summary>
/// Cursor for pagination.
/// </summary>
public string? Cursor { get; init; }
}
/// <summary>
/// Incident response DTO.
/// </summary>
public sealed record IncidentResponse
{
public required string IncidentId { get; init; }
public required string TenantId { get; init; }
public required string EventKind { get; init; }
public required string Status { get; init; }
public required string Severity { get; init; }
public required string Title { get; init; }
public string? Description { get; init; }
public required int EventCount { get; init; }
public required DateTimeOffset FirstOccurrence { get; init; }
public required DateTimeOffset LastOccurrence { get; init; }
public string? AcknowledgedBy { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public List<string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Incident list response with pagination.
/// </summary>
public sealed record IncidentListResponse
{
public required List<IncidentResponse> Incidents { get; init; }
public required int TotalCount { get; init; }
public string? NextCursor { get; init; }
}
/// <summary>
/// Request to acknowledge an incident.
/// </summary>
public sealed record IncidentAckRequest
{
/// <summary>
/// Actor performing the acknowledgement.
/// </summary>
public string? Actor { get; init; }
/// <summary>
/// Optional comment.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Request to resolve an incident.
/// </summary>
public sealed record IncidentResolveRequest
{
/// <summary>
/// Actor resolving the incident.
/// </summary>
public string? Actor { get; init; }
/// <summary>
/// Resolution reason.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Optional comment.
/// </summary>
public string? Comment { get; init; }
}
/// <summary>
/// Delivery history item for an incident.
/// </summary>
public sealed record DeliveryHistoryItem
{
public required string DeliveryId { get; init; }
public required string ChannelType { get; init; }
public required string ChannelName { get; init; }
public required string Status { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? ErrorMessage { get; init; }
public int Attempts { get; init; }
}

View File

@@ -1,45 +1,45 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record LocalizationBundleUpsertRequest
{
public string? Locale { get; init; }
public string? BundleKey { get; init; }
public IReadOnlyDictionary<string, string>? Strings { get; init; }
public bool? IsDefault { get; init; }
public string? ParentLocale { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to resolve localized strings.
/// </summary>
public sealed record LocalizationResolveRequest
{
public string? BundleKey { get; init; }
public IReadOnlyList<string>? StringKeys { get; init; }
public string? Locale { get; init; }
}
/// <summary>
/// Response containing resolved localized strings.
/// </summary>
public sealed record LocalizationResolveResponse
{
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
public required string RequestedLocale { get; init; }
public required IReadOnlyList<string> FallbackChain { get; init; }
}
/// <summary>
/// Result for a single localized string.
/// </summary>
public sealed record LocalizedStringResult
{
public required string Value { get; init; }
public required string ResolvedLocale { get; init; }
public required bool UsedFallback { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record LocalizationBundleUpsertRequest
{
public string? Locale { get; init; }
public string? BundleKey { get; init; }
public IReadOnlyDictionary<string, string>? Strings { get; init; }
public bool? IsDefault { get; init; }
public string? ParentLocale { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to resolve localized strings.
/// </summary>
public sealed record LocalizationResolveRequest
{
public string? BundleKey { get; init; }
public IReadOnlyList<string>? StringKeys { get; init; }
public string? Locale { get; init; }
}
/// <summary>
/// Response containing resolved localized strings.
/// </summary>
public sealed record LocalizationResolveResponse
{
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
public required string RequestedLocale { get; init; }
public required IReadOnlyList<string> FallbackChain { get; init; }
}
/// <summary>
/// Result for a single localized string.
/// </summary>
public sealed record LocalizedStringResult
{
public required string Value { get; init; }
public required string ResolvedLocale { get; init; }
public required bool UsedFallback { get; init; }
}

View File

@@ -1,35 +1,35 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request payload for acknowledging a pack approval decision.
/// </summary>
public sealed class PackApprovalAckRequest
{
/// <summary>
/// Acknowledgement token from the notification.
/// </summary>
[Required]
[JsonPropertyName("ackToken")]
public string AckToken { get; init; } = string.Empty;
/// <summary>
/// Approval decision: "approved" or "rejected".
/// </summary>
[JsonPropertyName("decision")]
public string? Decision { get; init; }
/// <summary>
/// Optional comment for audit trail.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
/// <summary>
/// Identity acknowledging the approval.
/// </summary>
[JsonPropertyName("actor")]
public string? Actor { get; init; }
}
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request payload for acknowledging a pack approval decision.
/// </summary>
public sealed class PackApprovalAckRequest
{
/// <summary>
/// Acknowledgement token from the notification.
/// </summary>
[Required]
[JsonPropertyName("ackToken")]
public string AckToken { get; init; } = string.Empty;
/// <summary>
/// Approval decision: "approved" or "rejected".
/// </summary>
[JsonPropertyName("decision")]
public string? Decision { get; init; }
/// <summary>
/// Optional comment for audit trail.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
/// <summary>
/// Identity acknowledging the approval.
/// </summary>
[JsonPropertyName("actor")]
public string? Actor { get; init; }
}

View File

@@ -1,88 +1,88 @@
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request payload for pack approval events from Task Runner.
/// See: docs/notifications/pack-approvals-contract.md
/// </summary>
public sealed class PackApprovalRequest
{
/// <summary>
/// Unique event identifier for deduplication.
/// </summary>
[JsonPropertyName("eventId")]
public Guid EventId { get; init; }
/// <summary>
/// Event timestamp in UTC (ISO 8601).
/// </summary>
[JsonPropertyName("issuedAt")]
public DateTimeOffset IssuedAt { get; init; }
/// <summary>
/// Event type: pack.approval.requested, pack.approval.updated, pack.policy.hold, pack.policy.released.
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
/// <summary>
/// Package identifier in PURL format.
/// </summary>
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
/// <summary>
/// Policy metadata (id and version).
/// </summary>
[JsonPropertyName("policy")]
public PackApprovalPolicy? Policy { get; init; }
/// <summary>
/// Current approval state: pending, approved, rejected, hold, expired.
/// </summary>
[JsonPropertyName("decision")]
public string Decision { get; init; } = string.Empty;
/// <summary>
/// Identity that triggered the event.
/// </summary>
[JsonPropertyName("actor")]
public string Actor { get; init; } = string.Empty;
/// <summary>
/// Opaque token for Task Runner resume flow. Echoed in X-Resume-After header.
/// </summary>
[JsonPropertyName("resumeToken")]
public string? ResumeToken { get; init; }
/// <summary>
/// Human-readable summary for notifications.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Custom key-value metadata labels.
/// </summary>
[JsonPropertyName("labels")]
public Dictionary<string, string>? Labels { get; init; }
}
/// <summary>
/// Policy metadata associated with a pack approval.
/// </summary>
public sealed class PackApprovalPolicy
{
/// <summary>
/// Policy identifier.
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; init; }
/// <summary>
/// Policy version.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
}
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request payload for pack approval events from Task Runner.
/// See: docs/notifications/pack-approvals-contract.md
/// </summary>
public sealed class PackApprovalRequest
{
/// <summary>
/// Unique event identifier for deduplication.
/// </summary>
[JsonPropertyName("eventId")]
public Guid EventId { get; init; }
/// <summary>
/// Event timestamp in UTC (ISO 8601).
/// </summary>
[JsonPropertyName("issuedAt")]
public DateTimeOffset IssuedAt { get; init; }
/// <summary>
/// Event type: pack.approval.requested, pack.approval.updated, pack.policy.hold, pack.policy.released.
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = string.Empty;
/// <summary>
/// Package identifier in PURL format.
/// </summary>
[JsonPropertyName("packId")]
public string PackId { get; init; } = string.Empty;
/// <summary>
/// Policy metadata (id and version).
/// </summary>
[JsonPropertyName("policy")]
public PackApprovalPolicy? Policy { get; init; }
/// <summary>
/// Current approval state: pending, approved, rejected, hold, expired.
/// </summary>
[JsonPropertyName("decision")]
public string Decision { get; init; } = string.Empty;
/// <summary>
/// Identity that triggered the event.
/// </summary>
[JsonPropertyName("actor")]
public string Actor { get; init; } = string.Empty;
/// <summary>
/// Opaque token for Task Runner resume flow. Echoed in X-Resume-After header.
/// </summary>
[JsonPropertyName("resumeToken")]
public string? ResumeToken { get; init; }
/// <summary>
/// Human-readable summary for notifications.
/// </summary>
[JsonPropertyName("summary")]
public string? Summary { get; init; }
/// <summary>
/// Custom key-value metadata labels.
/// </summary>
[JsonPropertyName("labels")]
public Dictionary<string, string>? Labels { get; init; }
}
/// <summary>
/// Policy metadata associated with a pack approval.
/// </summary>
public sealed class PackApprovalPolicy
{
/// <summary>
/// Policy identifier.
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; init; }
/// <summary>
/// Policy version.
/// </summary>
[JsonPropertyName("version")]
public string? Version { get; init; }
}

View File

@@ -1,60 +1,60 @@
using System.Collections.Immutable;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create or update a quiet hours schedule.
/// </summary>
public sealed class QuietHoursUpsertRequest
{
public required string Name { get; init; }
public required string CronExpression { get; init; }
public required TimeSpan Duration { get; init; }
public required string TimeZone { get; init; }
public string? ChannelId { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a maintenance window.
/// </summary>
public sealed class MaintenanceWindowUpsertRequest
{
public required string Name { get; init; }
public required DateTimeOffset StartsAt { get; init; }
public required DateTimeOffset EndsAt { get; init; }
public bool? SuppressNotifications { get; init; }
public string? Reason { get; init; }
public ImmutableArray<string> ChannelIds { get; init; } = [];
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a throttle configuration.
/// </summary>
public sealed class ThrottleConfigUpsertRequest
{
public required string Name { get; init; }
public required TimeSpan DefaultWindow { get; init; }
public int? MaxNotificationsPerWindow { get; init; }
public string? ChannelId { get; init; }
public bool? IsDefault { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create an operator override.
/// </summary>
public sealed class OperatorOverrideCreateRequest
{
public required string OverrideType { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public string? ChannelId { get; init; }
public string? RuleId { get; init; }
public string? Reason { get; init; }
}
using System.Collections.Immutable;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create or update a quiet hours schedule.
/// </summary>
public sealed class QuietHoursUpsertRequest
{
public required string Name { get; init; }
public required string CronExpression { get; init; }
public required TimeSpan Duration { get; init; }
public required string TimeZone { get; init; }
public string? ChannelId { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a maintenance window.
/// </summary>
public sealed class MaintenanceWindowUpsertRequest
{
public required string Name { get; init; }
public required DateTimeOffset StartsAt { get; init; }
public required DateTimeOffset EndsAt { get; init; }
public bool? SuppressNotifications { get; init; }
public string? Reason { get; init; }
public ImmutableArray<string> ChannelIds { get; init; } = [];
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create or update a throttle configuration.
/// </summary>
public sealed class ThrottleConfigUpsertRequest
{
public required string Name { get; init; }
public required TimeSpan DefaultWindow { get; init; }
public int? MaxNotificationsPerWindow { get; init; }
public string? ChannelId { get; init; }
public bool? IsDefault { get; init; }
public bool? Enabled { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to create an operator override.
/// </summary>
public sealed class OperatorOverrideCreateRequest
{
public required string OverrideType { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public string? ChannelId { get; init; }
public string? RuleId { get; init; }
public string? Reason { get; init; }
}

View File

@@ -1,143 +1,143 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Retention policy configuration request/response.
/// </summary>
public sealed record RetentionPolicyDto
{
/// <summary>
/// Retention period for delivery records in days.
/// </summary>
public int DeliveryRetentionDays { get; init; } = 90;
/// <summary>
/// Retention period for audit log entries in days.
/// </summary>
public int AuditRetentionDays { get; init; } = 365;
/// <summary>
/// Retention period for dead-letter entries in days.
/// </summary>
public int DeadLetterRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for storm tracking data in days.
/// </summary>
public int StormDataRetentionDays { get; init; } = 7;
/// <summary>
/// Retention period for inbox messages in days.
/// </summary>
public int InboxRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for event history in days.
/// </summary>
public int EventHistoryRetentionDays { get; init; } = 30;
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *";
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items.
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Request to update retention policy.
/// </summary>
public sealed record UpdateRetentionPolicyRequest
{
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention policy operations.
/// </summary>
public sealed record RetentionPolicyResponse
{
public required string TenantId { get; init; }
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention cleanup execution.
/// </summary>
public sealed record RetentionCleanupResponse
{
public required string TenantId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required double DurationMs { get; init; }
public required RetentionCleanupCountsDto Counts { get; init; }
}
/// <summary>
/// Cleanup counts DTO.
/// </summary>
public sealed record RetentionCleanupCountsDto
{
public int Deliveries { get; init; }
public int AuditEntries { get; init; }
public int DeadLetterEntries { get; init; }
public int StormData { get; init; }
public int InboxMessages { get; init; }
public int Events { get; init; }
public int Total { get; init; }
}
/// <summary>
/// Response for cleanup preview.
/// </summary>
public sealed record RetentionCleanupPreviewResponse
{
public required string TenantId { get; init; }
public required DateTimeOffset PreviewedAt { get; init; }
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
public required RetentionPolicyDto PolicyApplied { get; init; }
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
}
/// <summary>
/// Response for last cleanup execution.
/// </summary>
public sealed record RetentionCleanupExecutionResponse
{
public required string ExecutionId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required string Status { get; init; }
public RetentionCleanupCountsDto? Counts { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Response for cleanup all tenants.
/// </summary>
public sealed record RetentionCleanupAllResponse
{
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
public required int TotalDeleted { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Retention policy configuration request/response.
/// </summary>
public sealed record RetentionPolicyDto
{
/// <summary>
/// Retention period for delivery records in days.
/// </summary>
public int DeliveryRetentionDays { get; init; } = 90;
/// <summary>
/// Retention period for audit log entries in days.
/// </summary>
public int AuditRetentionDays { get; init; } = 365;
/// <summary>
/// Retention period for dead-letter entries in days.
/// </summary>
public int DeadLetterRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for storm tracking data in days.
/// </summary>
public int StormDataRetentionDays { get; init; } = 7;
/// <summary>
/// Retention period for inbox messages in days.
/// </summary>
public int InboxRetentionDays { get; init; } = 30;
/// <summary>
/// Retention period for event history in days.
/// </summary>
public int EventHistoryRetentionDays { get; init; } = 30;
/// <summary>
/// Whether automatic cleanup is enabled.
/// </summary>
public bool AutoCleanupEnabled { get; init; } = true;
/// <summary>
/// Cron expression for automatic cleanup schedule.
/// </summary>
public string CleanupSchedule { get; init; } = "0 2 * * *";
/// <summary>
/// Maximum records to delete per cleanup run.
/// </summary>
public int MaxDeletesPerRun { get; init; } = 10000;
/// <summary>
/// Whether to keep resolved/acknowledged deliveries longer.
/// </summary>
public bool ExtendResolvedRetention { get; init; } = true;
/// <summary>
/// Extension multiplier for resolved items.
/// </summary>
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Request to update retention policy.
/// </summary>
public sealed record UpdateRetentionPolicyRequest
{
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention policy operations.
/// </summary>
public sealed record RetentionPolicyResponse
{
public required string TenantId { get; init; }
public required RetentionPolicyDto Policy { get; init; }
}
/// <summary>
/// Response for retention cleanup execution.
/// </summary>
public sealed record RetentionCleanupResponse
{
public required string TenantId { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
public required DateTimeOffset ExecutedAt { get; init; }
public required double DurationMs { get; init; }
public required RetentionCleanupCountsDto Counts { get; init; }
}
/// <summary>
/// Cleanup counts DTO.
/// </summary>
public sealed record RetentionCleanupCountsDto
{
public int Deliveries { get; init; }
public int AuditEntries { get; init; }
public int DeadLetterEntries { get; init; }
public int StormData { get; init; }
public int InboxMessages { get; init; }
public int Events { get; init; }
public int Total { get; init; }
}
/// <summary>
/// Response for cleanup preview.
/// </summary>
public sealed record RetentionCleanupPreviewResponse
{
public required string TenantId { get; init; }
public required DateTimeOffset PreviewedAt { get; init; }
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
public required RetentionPolicyDto PolicyApplied { get; init; }
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
}
/// <summary>
/// Response for last cleanup execution.
/// </summary>
public sealed record RetentionCleanupExecutionResponse
{
public required string ExecutionId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public required string Status { get; init; }
public RetentionCleanupCountsDto? Counts { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Response for cleanup all tenants.
/// </summary>
public sealed record RetentionCleanupAllResponse
{
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
public required int SuccessCount { get; init; }
public required int FailureCount { get; init; }
public required int TotalDeleted { get; init; }
}

View File

@@ -1,128 +1,128 @@
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create or update a notification rule.
/// </summary>
public sealed record RuleCreateRequest
{
public required string RuleId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public bool Enabled { get; init; } = true;
public required RuleMatchRequest Match { get; init; }
public required List<RuleActionRequest> Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to update an existing rule.
/// </summary>
public sealed record RuleUpdateRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public bool? Enabled { get; init; }
public RuleMatchRequest? Match { get; init; }
public List<RuleActionRequest>? Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a rule (v2 API).
/// </summary>
public sealed record RuleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public bool? Enabled { get; init; }
public RuleMatchRequest? Match { get; init; }
public List<RuleActionRequest>? Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Rule match criteria.
/// </summary>
public sealed record RuleMatchRequest
{
public List<string>? EventKinds { get; init; }
public List<string>? Namespaces { get; init; }
public List<string>? Repositories { get; init; }
public List<string>? Digests { get; init; }
public List<string>? Labels { get; init; }
public List<string>? ComponentPurls { get; init; }
public string? MinSeverity { get; init; }
public List<string>? Verdicts { get; init; }
public bool? KevOnly { get; init; }
}
/// <summary>
/// Rule action configuration.
/// </summary>
public sealed record RuleActionRequest
{
public required string ActionId { get; init; }
public required string Channel { get; init; }
public string? Template { get; init; }
public string? Digest { get; init; }
public string? Throttle { get; init; } // ISO 8601 duration
public string? Locale { get; init; }
public bool Enabled { get; init; } = true;
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Rule response DTO.
/// </summary>
public sealed record RuleResponse
{
public required string RuleId { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required bool Enabled { get; init; }
public required RuleMatchResponse Match { get; init; }
public required List<RuleActionResponse> Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
public string? CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Rule match response.
/// </summary>
public sealed record RuleMatchResponse
{
public List<string> EventKinds { get; init; } = [];
public List<string> Namespaces { get; init; } = [];
public List<string> Repositories { get; init; } = [];
public List<string> Digests { get; init; } = [];
public List<string> Labels { get; init; } = [];
public List<string> ComponentPurls { get; init; } = [];
public string? MinSeverity { get; init; }
public List<string> Verdicts { get; init; } = [];
public bool KevOnly { get; init; }
}
/// <summary>
/// Rule action response.
/// </summary>
public sealed record RuleActionResponse
{
public required string ActionId { get; init; }
public required string Channel { get; init; }
public string? Template { get; init; }
public string? Digest { get; init; }
public string? Throttle { get; init; }
public string? Locale { get; init; }
public required bool Enabled { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
using System.Text.Json.Serialization;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to create or update a notification rule.
/// </summary>
public sealed record RuleCreateRequest
{
public required string RuleId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public bool Enabled { get; init; } = true;
public required RuleMatchRequest Match { get; init; }
public required List<RuleActionRequest> Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to update an existing rule.
/// </summary>
public sealed record RuleUpdateRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public bool? Enabled { get; init; }
public RuleMatchRequest? Match { get; init; }
public List<RuleActionRequest>? Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a rule (v2 API).
/// </summary>
public sealed record RuleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public bool? Enabled { get; init; }
public RuleMatchRequest? Match { get; init; }
public List<RuleActionRequest>? Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Rule match criteria.
/// </summary>
public sealed record RuleMatchRequest
{
public List<string>? EventKinds { get; init; }
public List<string>? Namespaces { get; init; }
public List<string>? Repositories { get; init; }
public List<string>? Digests { get; init; }
public List<string>? Labels { get; init; }
public List<string>? ComponentPurls { get; init; }
public string? MinSeverity { get; init; }
public List<string>? Verdicts { get; init; }
public bool? KevOnly { get; init; }
}
/// <summary>
/// Rule action configuration.
/// </summary>
public sealed record RuleActionRequest
{
public required string ActionId { get; init; }
public required string Channel { get; init; }
public string? Template { get; init; }
public string? Digest { get; init; }
public string? Throttle { get; init; } // ISO 8601 duration
public string? Locale { get; init; }
public bool Enabled { get; init; } = true;
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Rule response DTO.
/// </summary>
public sealed record RuleResponse
{
public required string RuleId { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required bool Enabled { get; init; }
public required RuleMatchResponse Match { get; init; }
public required List<RuleActionResponse> Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
public string? CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Rule match response.
/// </summary>
public sealed record RuleMatchResponse
{
public List<string> EventKinds { get; init; } = [];
public List<string> Namespaces { get; init; } = [];
public List<string> Repositories { get; init; } = [];
public List<string> Digests { get; init; } = [];
public List<string> Labels { get; init; } = [];
public List<string> ComponentPurls { get; init; } = [];
public string? MinSeverity { get; init; }
public List<string> Verdicts { get; init; } = [];
public bool KevOnly { get; init; }
}
/// <summary>
/// Rule action response.
/// </summary>
public sealed record RuleActionResponse
{
public required string ActionId { get; init; }
public required string Channel { get; init; }
public string? Template { get; init; }
public string? Digest { get; init; }
public string? Throttle { get; init; }
public string? Locale { get; init; }
public required bool Enabled { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}

View File

@@ -1,305 +1,305 @@
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to acknowledge a notification via signed token.
/// </summary>
public sealed record AckRequest
{
/// <summary>
/// Optional comment for the acknowledgement.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Optional metadata to include with the acknowledgement.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response from acknowledging a notification.
/// </summary>
public sealed record AckResponse
{
/// <summary>
/// Whether the acknowledgement was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The delivery ID that was acknowledged.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action that was performed.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the acknowledgement was processed.
/// </summary>
public DateTimeOffset? ProcessedAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Request to create an acknowledgement token.
/// </summary>
public sealed record CreateAckTokenRequest
{
/// <summary>
/// The delivery ID to create an ack token for.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
/// </summary>
public string? Action { get; init; }
/// <summary>
/// Optional expiration in hours. Default: 168 (7 days).
/// </summary>
public int? ExpirationHours { get; init; }
/// <summary>
/// Optional metadata to embed in the token.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response containing the created ack token.
/// </summary>
public sealed record CreateAckTokenResponse
{
/// <summary>
/// The signed token string.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// The full acknowledgement URL.
/// </summary>
public required string AckUrl { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Request to verify an ack token.
/// </summary>
public sealed record VerifyAckTokenRequest
{
/// <summary>
/// The token to verify.
/// </summary>
public string? Token { get; init; }
}
/// <summary>
/// Response from token verification.
/// </summary>
public sealed record VerifyAckTokenResponse
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The delivery ID embedded in the token.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action embedded in the token.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// Request to validate HTML content.
/// </summary>
public sealed record ValidateHtmlRequest
{
/// <summary>
/// The HTML content to validate.
/// </summary>
public string? Html { get; init; }
}
/// <summary>
/// Response from HTML validation.
/// </summary>
public sealed record ValidateHtmlResponse
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlStats? Stats { get; init; }
}
/// <summary>
/// An HTML security issue.
/// </summary>
public sealed record HtmlIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The element name if applicable.
/// </summary>
public string? Element { get; init; }
/// <summary>
/// The attribute name if applicable.
/// </summary>
public string? Attribute { get; init; }
}
/// <summary>
/// HTML content statistics.
/// </summary>
public sealed record HtmlStats
{
/// <summary>
/// Total character count.
/// </summary>
public int CharacterCount { get; init; }
/// <summary>
/// Number of HTML elements.
/// </summary>
public int ElementCount { get; init; }
/// <summary>
/// Maximum nesting depth.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Number of links.
/// </summary>
public int LinkCount { get; init; }
/// <summary>
/// Number of images.
/// </summary>
public int ImageCount { get; init; }
}
/// <summary>
/// Request to sanitize HTML content.
/// </summary>
public sealed record SanitizeHtmlRequest
{
/// <summary>
/// The HTML content to sanitize.
/// </summary>
public string? Html { get; init; }
/// <summary>
/// Whether to allow data: URLs. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Additional tags to allow.
/// </summary>
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
}
/// <summary>
/// Response containing sanitized HTML.
/// </summary>
public sealed record SanitizeHtmlResponse
{
/// <summary>
/// The sanitized HTML content.
/// </summary>
public required string SanitizedHtml { get; init; }
/// <summary>
/// Whether any changes were made.
/// </summary>
public required bool WasModified { get; init; }
}
/// <summary>
/// Request to rotate a webhook secret.
/// </summary>
public sealed record RotateWebhookSecretRequest
{
/// <summary>
/// The channel ID to rotate the secret for.
/// </summary>
public string? ChannelId { get; init; }
}
/// <summary>
/// Response from webhook secret rotation.
/// </summary>
public sealed record RotateWebhookSecretResponse
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only shown once).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to acknowledge a notification via signed token.
/// </summary>
public sealed record AckRequest
{
/// <summary>
/// Optional comment for the acknowledgement.
/// </summary>
public string? Comment { get; init; }
/// <summary>
/// Optional metadata to include with the acknowledgement.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response from acknowledging a notification.
/// </summary>
public sealed record AckResponse
{
/// <summary>
/// Whether the acknowledgement was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The delivery ID that was acknowledged.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action that was performed.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the acknowledgement was processed.
/// </summary>
public DateTimeOffset? ProcessedAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Request to create an acknowledgement token.
/// </summary>
public sealed record CreateAckTokenRequest
{
/// <summary>
/// The delivery ID to create an ack token for.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
/// </summary>
public string? Action { get; init; }
/// <summary>
/// Optional expiration in hours. Default: 168 (7 days).
/// </summary>
public int? ExpirationHours { get; init; }
/// <summary>
/// Optional metadata to embed in the token.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Response containing the created ack token.
/// </summary>
public sealed record CreateAckTokenResponse
{
/// <summary>
/// The signed token string.
/// </summary>
public required string Token { get; init; }
/// <summary>
/// The full acknowledgement URL.
/// </summary>
public required string AckUrl { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Request to verify an ack token.
/// </summary>
public sealed record VerifyAckTokenRequest
{
/// <summary>
/// The token to verify.
/// </summary>
public string? Token { get; init; }
}
/// <summary>
/// Response from token verification.
/// </summary>
public sealed record VerifyAckTokenResponse
{
/// <summary>
/// Whether the token is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// The delivery ID embedded in the token.
/// </summary>
public string? DeliveryId { get; init; }
/// <summary>
/// The action embedded in the token.
/// </summary>
public string? Action { get; init; }
/// <summary>
/// When the token expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Failure reason if invalid.
/// </summary>
public string? FailureReason { get; init; }
}
/// <summary>
/// Request to validate HTML content.
/// </summary>
public sealed record ValidateHtmlRequest
{
/// <summary>
/// The HTML content to validate.
/// </summary>
public string? Html { get; init; }
}
/// <summary>
/// Response from HTML validation.
/// </summary>
public sealed record ValidateHtmlResponse
{
/// <summary>
/// Whether the HTML is safe.
/// </summary>
public required bool IsSafe { get; init; }
/// <summary>
/// List of security issues found.
/// </summary>
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
/// <summary>
/// Statistics about the HTML content.
/// </summary>
public HtmlStats? Stats { get; init; }
}
/// <summary>
/// An HTML security issue.
/// </summary>
public sealed record HtmlIssue
{
/// <summary>
/// The type of issue.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Description of the issue.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// The element name if applicable.
/// </summary>
public string? Element { get; init; }
/// <summary>
/// The attribute name if applicable.
/// </summary>
public string? Attribute { get; init; }
}
/// <summary>
/// HTML content statistics.
/// </summary>
public sealed record HtmlStats
{
/// <summary>
/// Total character count.
/// </summary>
public int CharacterCount { get; init; }
/// <summary>
/// Number of HTML elements.
/// </summary>
public int ElementCount { get; init; }
/// <summary>
/// Maximum nesting depth.
/// </summary>
public int MaxDepth { get; init; }
/// <summary>
/// Number of links.
/// </summary>
public int LinkCount { get; init; }
/// <summary>
/// Number of images.
/// </summary>
public int ImageCount { get; init; }
}
/// <summary>
/// Request to sanitize HTML content.
/// </summary>
public sealed record SanitizeHtmlRequest
{
/// <summary>
/// The HTML content to sanitize.
/// </summary>
public string? Html { get; init; }
/// <summary>
/// Whether to allow data: URLs. Default: false.
/// </summary>
public bool AllowDataUrls { get; init; }
/// <summary>
/// Additional tags to allow.
/// </summary>
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
}
/// <summary>
/// Response containing sanitized HTML.
/// </summary>
public sealed record SanitizeHtmlResponse
{
/// <summary>
/// The sanitized HTML content.
/// </summary>
public required string SanitizedHtml { get; init; }
/// <summary>
/// Whether any changes were made.
/// </summary>
public required bool WasModified { get; init; }
}
/// <summary>
/// Request to rotate a webhook secret.
/// </summary>
public sealed record RotateWebhookSecretRequest
{
/// <summary>
/// The channel ID to rotate the secret for.
/// </summary>
public string? ChannelId { get; init; }
}
/// <summary>
/// Response from webhook secret rotation.
/// </summary>
public sealed record RotateWebhookSecretResponse
{
/// <summary>
/// Whether rotation succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The new secret (only shown once).
/// </summary>
public string? NewSecret { get; init; }
/// <summary>
/// When the new secret becomes active.
/// </summary>
public DateTimeOffset? ActiveAt { get; init; }
/// <summary>
/// When the old secret expires.
/// </summary>
public DateTimeOffset? OldSecretExpiresAt { get; init; }
/// <summary>
/// Error message if unsuccessful.
/// </summary>
public string? Error { get; init; }
}

View File

@@ -1,30 +1,30 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to run a historical simulation against past events.
/// </summary>
public sealed class SimulationRunRequest
{
public required DateTimeOffset PeriodStart { get; init; }
public required DateTimeOffset PeriodEnd { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableArray<string> EventKinds { get; init; } = [];
public int MaxEvents { get; init; } = 1000;
public bool IncludeNonMatches { get; init; } = true;
public bool EvaluateThrottling { get; init; } = true;
public bool EvaluateQuietHours { get; init; } = true;
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
/// <summary>
/// Request to simulate a single event against current rules.
/// </summary>
public sealed class SimulateSingleEventRequest
{
public required JsonObject EventPayload { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to run a historical simulation against past events.
/// </summary>
public sealed class SimulationRunRequest
{
public required DateTimeOffset PeriodStart { get; init; }
public required DateTimeOffset PeriodEnd { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public ImmutableArray<string> EventKinds { get; init; } = [];
public int MaxEvents { get; init; } = 1000;
public bool IncludeNonMatches { get; init; } = true;
public bool EvaluateThrottling { get; init; } = true;
public bool EvaluateQuietHours { get; init; } = true;
public DateTimeOffset? EvaluationTimestamp { get; init; }
}
/// <summary>
/// Request to simulate a single event against current rules.
/// </summary>
public sealed class SimulateSingleEventRequest
{
public required JsonObject EventPayload { get; init; }
public ImmutableArray<string> RuleIds { get; init; } = [];
public DateTimeOffset? EvaluationTimestamp { get; init; }
}

View File

@@ -1,149 +1,149 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to preview a template rendering.
/// </summary>
public sealed record TemplatePreviewRequest
{
/// <summary>
/// Template ID to preview (mutually exclusive with TemplateBody).
/// </summary>
public string? TemplateId { get; init; }
/// <summary>
/// Raw template body to preview (mutually exclusive with TemplateId).
/// </summary>
public string? TemplateBody { get; init; }
/// <summary>
/// Sample event payload for rendering.
/// </summary>
public JsonObject? SamplePayload { get; init; }
/// <summary>
/// Event kind for context.
/// </summary>
public string? EventKind { get; init; }
/// <summary>
/// Sample attributes.
/// </summary>
public Dictionary<string, string>? SampleAttributes { get; init; }
/// <summary>
/// Output format override.
/// </summary>
public string? OutputFormat { get; init; }
/// <summary>
/// Whether to include provenance links in preview output.
/// </summary>
public bool? IncludeProvenance { get; init; }
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Optional format override for rendering.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}
/// <summary>
/// Response from template preview.
/// </summary>
public sealed record TemplatePreviewResponse
{
/// <summary>
/// Rendered body content.
/// </summary>
public required string RenderedBody { get; init; }
/// <summary>
/// Rendered subject (if applicable).
/// </summary>
public string? RenderedSubject { get; init; }
/// <summary>
/// Content hash for deduplication.
/// </summary>
public required string BodyHash { get; init; }
/// <summary>
/// Output format used.
/// </summary>
public required string Format { get; init; }
/// <summary>
/// Validation warnings (if any).
/// </summary>
public List<string>? Warnings { get; init; }
}
/// <summary>
/// Request to create or update a template.
/// </summary>
public sealed record TemplateCreateRequest
{
public required string TemplateId { get; init; }
public required string Key { get; init; }
public required string ChannelType { get; init; }
public required string Locale { get; init; }
public required string Body { get; init; }
public string? RenderMode { get; init; }
public string? Format { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a template (v2 API).
/// </summary>
public sealed record TemplateUpsertRequest
{
public required string Key { get; init; }
public NotifyChannelType? ChannelType { get; init; }
public string? Locale { get; init; }
public required string Body { get; init; }
public NotifyTemplateRenderMode? RenderMode { get; init; }
public NotifyDeliveryFormat? Format { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Template response DTO.
/// </summary>
public sealed record TemplateResponse
{
public required string TemplateId { get; init; }
public required string TenantId { get; init; }
public required string Key { get; init; }
public required string ChannelType { get; init; }
public required string Locale { get; init; }
public required string Body { get; init; }
public required string RenderMode { get; init; }
public required string Format { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
public string? CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Template list query parameters.
/// </summary>
public sealed record TemplateListQuery
{
public string? KeyPrefix { get; init; }
public string? ChannelType { get; init; }
public string? Locale { get; init; }
public int? Limit { get; init; }
}
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
/// <summary>
/// Request to preview a template rendering.
/// </summary>
public sealed record TemplatePreviewRequest
{
/// <summary>
/// Template ID to preview (mutually exclusive with TemplateBody).
/// </summary>
public string? TemplateId { get; init; }
/// <summary>
/// Raw template body to preview (mutually exclusive with TemplateId).
/// </summary>
public string? TemplateBody { get; init; }
/// <summary>
/// Sample event payload for rendering.
/// </summary>
public JsonObject? SamplePayload { get; init; }
/// <summary>
/// Event kind for context.
/// </summary>
public string? EventKind { get; init; }
/// <summary>
/// Sample attributes.
/// </summary>
public Dictionary<string, string>? SampleAttributes { get; init; }
/// <summary>
/// Output format override.
/// </summary>
public string? OutputFormat { get; init; }
/// <summary>
/// Whether to include provenance links in preview output.
/// </summary>
public bool? IncludeProvenance { get; init; }
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Optional format override for rendering.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}
/// <summary>
/// Response from template preview.
/// </summary>
public sealed record TemplatePreviewResponse
{
/// <summary>
/// Rendered body content.
/// </summary>
public required string RenderedBody { get; init; }
/// <summary>
/// Rendered subject (if applicable).
/// </summary>
public string? RenderedSubject { get; init; }
/// <summary>
/// Content hash for deduplication.
/// </summary>
public required string BodyHash { get; init; }
/// <summary>
/// Output format used.
/// </summary>
public required string Format { get; init; }
/// <summary>
/// Validation warnings (if any).
/// </summary>
public List<string>? Warnings { get; init; }
}
/// <summary>
/// Request to create or update a template.
/// </summary>
public sealed record TemplateCreateRequest
{
public required string TemplateId { get; init; }
public required string Key { get; init; }
public required string ChannelType { get; init; }
public required string Locale { get; init; }
public required string Body { get; init; }
public string? RenderMode { get; init; }
public string? Format { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a template (v2 API).
/// </summary>
public sealed record TemplateUpsertRequest
{
public required string Key { get; init; }
public NotifyChannelType? ChannelType { get; init; }
public string? Locale { get; init; }
public required string Body { get; init; }
public NotifyTemplateRenderMode? RenderMode { get; init; }
public NotifyDeliveryFormat? Format { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Template response DTO.
/// </summary>
public sealed record TemplateResponse
{
public required string TemplateId { get; init; }
public required string TenantId { get; init; }
public required string Key { get; init; }
public required string ChannelType { get; init; }
public required string Locale { get; init; }
public required string Body { get; init; }
public required string RenderMode { get; init; }
public required string Format { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
public string? CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? UpdatedBy { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Template list query parameters.
/// </summary>
public sealed record TemplateListQuery
{
public string? KeyPrefix { get; init; }
public string? ChannelType { get; init; }
public string? Locale { get; init; }
public int? Limit { get; init; }
}

View File

@@ -1,194 +1,194 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Fallback;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST API endpoints for fallback handler operations.
/// </summary>
public static class FallbackEndpoints
{
/// <summary>
/// Maps fallback API endpoints.
/// </summary>
public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/fallback")
.WithTags("Fallback")
.WithOpenApi();
// Get fallback statistics
group.MapGet("/statistics", async (
int? windowHours,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null;
var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken);
return Results.Ok(new
{
stats.TenantId,
window = stats.Window.ToString(),
stats.TotalDeliveries,
stats.PrimarySuccesses,
stats.FallbackAttempts,
stats.FallbackSuccesses,
stats.ExhaustedDeliveries,
successRate = $"{stats.SuccessRate:P1}",
fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}",
failuresByChannel = stats.FailuresByChannel.ToDictionary(
kvp => kvp.Key.ToString(),
kvp => kvp.Value)
});
})
.WithName("GetFallbackStatistics")
.WithSummary("Gets fallback handling statistics for a tenant");
// Get fallback chain for a channel
group.MapGet("/chains/{channelType}", async (
NotifyChannelType channelType,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken);
return Results.Ok(new
{
tenantId,
primaryChannel = channelType.ToString(),
fallbackChain = chain.Select(c => c.ToString()).ToList(),
chainLength = chain.Count
});
})
.WithName("GetFallbackChain")
.WithSummary("Gets the fallback chain for a channel type");
// Set fallback chain for a channel
group.MapPut("/chains/{channelType}", async (
NotifyChannelType channelType,
SetFallbackChainRequest request,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
var chain = request.FallbackChain
.Select(s => Enum.TryParse<NotifyChannelType>(s, out var t) ? t : (NotifyChannelType?)null)
.Where(t => t.HasValue)
.Select(t => t!.Value)
.ToList();
await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken);
return Results.Ok(new
{
message = "Fallback chain updated successfully",
primaryChannel = channelType.ToString(),
fallbackChain = chain.Select(c => c.ToString()).ToList()
});
})
.WithName("SetFallbackChain")
.WithSummary("Sets a custom fallback chain for a channel type");
// Test fallback resolution
group.MapPost("/test", async (
TestFallbackRequest request,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
if (!Enum.TryParse<NotifyChannelType>(request.FailedChannelType, out var channelType))
{
return Results.BadRequest(new { error = $"Invalid channel type: {request.FailedChannelType}" });
}
var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
// Simulate failure recording
await fallbackHandler.RecordFailureAsync(
tenantId, deliveryId, channelType, "Test failure", cancellationToken);
// Get fallback result
var result = await fallbackHandler.GetFallbackAsync(
tenantId, channelType, deliveryId, cancellationToken);
// Clean up test state
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
return Results.Ok(new
{
testDeliveryId = deliveryId,
result.HasFallback,
nextChannelType = result.NextChannelType?.ToString(),
result.AttemptNumber,
result.TotalChannels,
result.IsExhausted,
result.ExhaustionReason,
failedChannels = result.FailedChannels.Select(f => new
{
channelType = f.ChannelType.ToString(),
f.Reason,
f.FailedAt,
f.AttemptNumber
}).ToList()
});
})
.WithName("TestFallback")
.WithSummary("Tests fallback resolution without affecting real deliveries");
// Clear delivery state
group.MapDelete("/deliveries/{deliveryId}", async (
string deliveryId,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" });
})
.WithName("ClearDeliveryFallbackState")
.WithSummary("Clears fallback state for a specific delivery");
return group;
}
}
/// <summary>
/// Request to set a custom fallback chain.
/// </summary>
public sealed record SetFallbackChainRequest
{
/// <summary>
/// Ordered list of fallback channel types.
/// </summary>
public required List<string> FallbackChain { get; init; }
}
/// <summary>
/// Request to test fallback resolution.
/// </summary>
public sealed record TestFallbackRequest
{
/// <summary>
/// The channel type that "failed".
/// </summary>
public required string FailedChannelType { get; init; }
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Fallback;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST API endpoints for fallback handler operations.
/// </summary>
public static class FallbackEndpoints
{
/// <summary>
/// Maps fallback API endpoints.
/// </summary>
public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/fallback")
.WithTags("Fallback")
.WithOpenApi();
// Get fallback statistics
group.MapGet("/statistics", async (
int? windowHours,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null;
var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken);
return Results.Ok(new
{
stats.TenantId,
window = stats.Window.ToString(),
stats.TotalDeliveries,
stats.PrimarySuccesses,
stats.FallbackAttempts,
stats.FallbackSuccesses,
stats.ExhaustedDeliveries,
successRate = $"{stats.SuccessRate:P1}",
fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}",
failuresByChannel = stats.FailuresByChannel.ToDictionary(
kvp => kvp.Key.ToString(),
kvp => kvp.Value)
});
})
.WithName("GetFallbackStatistics")
.WithSummary("Gets fallback handling statistics for a tenant");
// Get fallback chain for a channel
group.MapGet("/chains/{channelType}", async (
NotifyChannelType channelType,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken);
return Results.Ok(new
{
tenantId,
primaryChannel = channelType.ToString(),
fallbackChain = chain.Select(c => c.ToString()).ToList(),
chainLength = chain.Count
});
})
.WithName("GetFallbackChain")
.WithSummary("Gets the fallback chain for a channel type");
// Set fallback chain for a channel
group.MapPut("/chains/{channelType}", async (
NotifyChannelType channelType,
SetFallbackChainRequest request,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
var chain = request.FallbackChain
.Select(s => Enum.TryParse<NotifyChannelType>(s, out var t) ? t : (NotifyChannelType?)null)
.Where(t => t.HasValue)
.Select(t => t!.Value)
.ToList();
await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken);
return Results.Ok(new
{
message = "Fallback chain updated successfully",
primaryChannel = channelType.ToString(),
fallbackChain = chain.Select(c => c.ToString()).ToList()
});
})
.WithName("SetFallbackChain")
.WithSummary("Sets a custom fallback chain for a channel type");
// Test fallback resolution
group.MapPost("/test", async (
TestFallbackRequest request,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
if (!Enum.TryParse<NotifyChannelType>(request.FailedChannelType, out var channelType))
{
return Results.BadRequest(new { error = $"Invalid channel type: {request.FailedChannelType}" });
}
var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
// Simulate failure recording
await fallbackHandler.RecordFailureAsync(
tenantId, deliveryId, channelType, "Test failure", cancellationToken);
// Get fallback result
var result = await fallbackHandler.GetFallbackAsync(
tenantId, channelType, deliveryId, cancellationToken);
// Clean up test state
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
return Results.Ok(new
{
testDeliveryId = deliveryId,
result.HasFallback,
nextChannelType = result.NextChannelType?.ToString(),
result.AttemptNumber,
result.TotalChannels,
result.IsExhausted,
result.ExhaustionReason,
failedChannels = result.FailedChannels.Select(f => new
{
channelType = f.ChannelType.ToString(),
f.Reason,
f.FailedAt,
f.AttemptNumber
}).ToList()
});
})
.WithName("TestFallback")
.WithSummary("Tests fallback resolution without affecting real deliveries");
// Clear delivery state
group.MapDelete("/deliveries/{deliveryId}", async (
string deliveryId,
HttpContext context,
IFallbackHandler fallbackHandler,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" });
})
.WithName("ClearDeliveryFallbackState")
.WithSummary("Clears fallback state for a specific delivery");
return group;
}
}
/// <summary>
/// Request to set a custom fallback chain.
/// </summary>
public sealed record SetFallbackChainRequest
{
/// <summary>
/// Ordered list of fallback channel types.
/// </summary>
public required List<string> FallbackChain { get; init; }
}
/// <summary>
/// Request to test fallback resolution.
/// </summary>
public sealed record TestFallbackRequest
{
/// <summary>
/// The channel type that "failed".
/// </summary>
public required string FailedChannelType { get; init; }
}

View File

@@ -1,315 +1,315 @@
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.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// Maps incident (delivery) management endpoints.
/// </summary>
public static class IncidentEndpoints
{
public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/incidents")
.WithTags("Incidents");
group.MapGet("/", ListIncidentsAsync)
.WithName("ListIncidents")
.WithSummary("Lists notification incidents (deliveries)");
group.MapGet("/{deliveryId}", GetIncidentAsync)
.WithName("GetIncident")
.WithSummary("Gets an incident by delivery ID");
group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
.WithName("AcknowledgeIncident")
.WithSummary("Acknowledges an incident");
group.MapGet("/stats", GetIncidentStatsAsync)
.WithName("GetIncidentStats")
.WithSummary("Gets incident statistics");
return app;
}
private static async Task<IResult> ListIncidentsAsync(
HttpContext context,
INotifyDeliveryRepository deliveries,
string? status = null,
string? kind = null,
string? ruleId = null,
int? limit = null,
string? continuationToken = null,
DateTimeOffset? since = null)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
// Query deliveries with filtering
var queryResult = await deliveries.QueryAsync(
tenantId,
since,
status,
limit ?? 50,
continuationToken,
context.RequestAborted);
IEnumerable<NotifyDelivery> filtered = queryResult.Items;
// Apply additional filters not supported by the repository
if (!string.IsNullOrWhiteSpace(kind))
{
filtered = filtered.Where(d => d.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(ruleId))
{
filtered = filtered.Where(d => d.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase));
}
var response = filtered.Select(MapToDeliveryResponse).ToList();
// Add continuation token header for pagination
if (!string.IsNullOrWhiteSpace(queryResult.ContinuationToken))
{
context.Response.Headers["X-Continuation-Token"] = queryResult.ContinuationToken;
}
return Results.Ok(response);
}
private static async Task<IResult> GetIncidentAsync(
HttpContext context,
string deliveryId,
INotifyDeliveryRepository deliveries)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
if (delivery is null)
{
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
}
return Results.Ok(MapToDeliveryResponse(delivery));
}
private static async Task<IResult> AcknowledgeIncidentAsync(
HttpContext context,
string deliveryId,
DeliveryAckRequest request,
INotifyDeliveryRepository deliveries,
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 delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
if (delivery is null)
{
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
}
// Update delivery status based on acknowledgment
var newStatus = request.Resolution?.ToLowerInvariant() switch
{
"resolved" => NotifyDeliveryStatus.Delivered,
"dismissed" => NotifyDeliveryStatus.Failed,
_ => delivery.Status
};
var attempt = new NotifyDeliveryAttempt(
timestamp: timeProvider.GetUtcNow(),
status: NotifyDeliveryAttemptStatus.Success,
reason: $"Acknowledged by {actor}: {request.Comment ?? request.Resolution ?? "ack"}");
var updated = NotifyDelivery.Create(
deliveryId: delivery.DeliveryId,
tenantId: delivery.TenantId,
ruleId: delivery.RuleId,
actionId: delivery.ActionId,
eventId: delivery.EventId,
kind: delivery.Kind,
status: newStatus,
statusReason: request.Comment ?? $"Acknowledged: {request.Resolution}",
rendered: delivery.Rendered,
attempts: delivery.Attempts.Add(attempt),
metadata: delivery.Metadata,
createdAt: delivery.CreatedAt,
sentAt: delivery.SentAt,
completedAt: timeProvider.GetUtcNow());
await deliveries.UpdateAsync(updated, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "incident.acknowledged", deliveryId, "incident", new
{
deliveryId,
request.Resolution,
request.Comment
}, timeProvider, context.RequestAborted);
return Results.Ok(MapToDeliveryResponse(updated));
}
private static async Task<IResult> GetIncidentStatsAsync(
HttpContext context,
INotifyDeliveryRepository deliveries)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var allDeliveries = await deliveries.ListAsync(tenantId, context.RequestAborted);
var stats = new DeliveryStatsResponse
{
Total = allDeliveries.Count,
Pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending),
Delivered = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Delivered),
Failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed),
ByKind = allDeliveries
.GroupBy(d => d.Kind)
.ToDictionary(g => g.Key, g => g.Count()),
ByRule = allDeliveries
.GroupBy(d => d.RuleId)
.ToDictionary(g => g.Key, g => g.Count())
};
return Results.Ok(stats);
}
private static DeliveryResponse MapToDeliveryResponse(NotifyDelivery delivery)
{
return new DeliveryResponse
{
DeliveryId = delivery.DeliveryId,
TenantId = delivery.TenantId,
RuleId = delivery.RuleId,
ActionId = delivery.ActionId,
EventId = delivery.EventId.ToString(),
Kind = delivery.Kind,
Status = delivery.Status.ToString(),
StatusReason = delivery.StatusReason,
AttemptCount = delivery.Attempts.Length,
LastAttempt = delivery.Attempts.Length > 0 ? delivery.Attempts[^1].Timestamp : null,
CreatedAt = delivery.CreatedAt,
SentAt = delivery.SentAt,
CompletedAt = delivery.CompletedAt,
Metadata = delivery.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
}
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 payloadNode = JsonSerializer.SerializeToNode(payload) as JsonObject;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["entityId"] = entityId,
["entityType"] = entityType,
["payload"] = payloadNode?.ToJsonString() ?? "{}"
};
await audit.AppendAsync(tenantId, action, actor, data, cancellationToken);
}
catch
{
// Ignore audit failures
}
}
private static object Error(string code, string message, HttpContext context) => new
{
error = new
{
code,
message,
traceId = context.TraceIdentifier
}
};
}
/// <summary>
/// Delivery acknowledgment request for v2 API.
/// </summary>
public sealed record DeliveryAckRequest
{
public string? Resolution { get; init; }
public string? Comment { get; init; }
}
/// <summary>
/// Delivery response DTO for v2 API.
/// </summary>
public sealed record DeliveryResponse
{
public required string DeliveryId { get; init; }
public required string TenantId { get; init; }
public required string RuleId { get; init; }
public required string ActionId { get; init; }
public required string EventId { get; init; }
public required string Kind { get; init; }
public required string Status { get; init; }
public string? StatusReason { get; init; }
public required int AttemptCount { get; init; }
public DateTimeOffset? LastAttempt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? SentAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Delivery statistics response for v2 API.
/// </summary>
public sealed record DeliveryStatsResponse
{
public required int Total { get; init; }
public required int Pending { get; init; }
public required int Delivered { get; init; }
public required int Failed { get; init; }
public required Dictionary<string, int> ByKind { get; init; }
public required Dictionary<string, int> ByRule { get; init; }
}
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.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// Maps incident (delivery) management endpoints.
/// </summary>
public static class IncidentEndpoints
{
public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/incidents")
.WithTags("Incidents");
group.MapGet("/", ListIncidentsAsync)
.WithName("ListIncidents")
.WithSummary("Lists notification incidents (deliveries)");
group.MapGet("/{deliveryId}", GetIncidentAsync)
.WithName("GetIncident")
.WithSummary("Gets an incident by delivery ID");
group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
.WithName("AcknowledgeIncident")
.WithSummary("Acknowledges an incident");
group.MapGet("/stats", GetIncidentStatsAsync)
.WithName("GetIncidentStats")
.WithSummary("Gets incident statistics");
return app;
}
private static async Task<IResult> ListIncidentsAsync(
HttpContext context,
INotifyDeliveryRepository deliveries,
string? status = null,
string? kind = null,
string? ruleId = null,
int? limit = null,
string? continuationToken = null,
DateTimeOffset? since = null)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
// Query deliveries with filtering
var queryResult = await deliveries.QueryAsync(
tenantId,
since,
status,
limit ?? 50,
continuationToken,
context.RequestAborted);
IEnumerable<NotifyDelivery> filtered = queryResult.Items;
// Apply additional filters not supported by the repository
if (!string.IsNullOrWhiteSpace(kind))
{
filtered = filtered.Where(d => d.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(ruleId))
{
filtered = filtered.Where(d => d.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase));
}
var response = filtered.Select(MapToDeliveryResponse).ToList();
// Add continuation token header for pagination
if (!string.IsNullOrWhiteSpace(queryResult.ContinuationToken))
{
context.Response.Headers["X-Continuation-Token"] = queryResult.ContinuationToken;
}
return Results.Ok(response);
}
private static async Task<IResult> GetIncidentAsync(
HttpContext context,
string deliveryId,
INotifyDeliveryRepository deliveries)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
if (delivery is null)
{
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
}
return Results.Ok(MapToDeliveryResponse(delivery));
}
private static async Task<IResult> AcknowledgeIncidentAsync(
HttpContext context,
string deliveryId,
DeliveryAckRequest request,
INotifyDeliveryRepository deliveries,
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 delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
if (delivery is null)
{
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
}
// Update delivery status based on acknowledgment
var newStatus = request.Resolution?.ToLowerInvariant() switch
{
"resolved" => NotifyDeliveryStatus.Delivered,
"dismissed" => NotifyDeliveryStatus.Failed,
_ => delivery.Status
};
var attempt = new NotifyDeliveryAttempt(
timestamp: timeProvider.GetUtcNow(),
status: NotifyDeliveryAttemptStatus.Success,
reason: $"Acknowledged by {actor}: {request.Comment ?? request.Resolution ?? "ack"}");
var updated = NotifyDelivery.Create(
deliveryId: delivery.DeliveryId,
tenantId: delivery.TenantId,
ruleId: delivery.RuleId,
actionId: delivery.ActionId,
eventId: delivery.EventId,
kind: delivery.Kind,
status: newStatus,
statusReason: request.Comment ?? $"Acknowledged: {request.Resolution}",
rendered: delivery.Rendered,
attempts: delivery.Attempts.Add(attempt),
metadata: delivery.Metadata,
createdAt: delivery.CreatedAt,
sentAt: delivery.SentAt,
completedAt: timeProvider.GetUtcNow());
await deliveries.UpdateAsync(updated, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "incident.acknowledged", deliveryId, "incident", new
{
deliveryId,
request.Resolution,
request.Comment
}, timeProvider, context.RequestAborted);
return Results.Ok(MapToDeliveryResponse(updated));
}
private static async Task<IResult> GetIncidentStatsAsync(
HttpContext context,
INotifyDeliveryRepository deliveries)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var allDeliveries = await deliveries.ListAsync(tenantId, context.RequestAborted);
var stats = new DeliveryStatsResponse
{
Total = allDeliveries.Count,
Pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending),
Delivered = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Delivered),
Failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed),
ByKind = allDeliveries
.GroupBy(d => d.Kind)
.ToDictionary(g => g.Key, g => g.Count()),
ByRule = allDeliveries
.GroupBy(d => d.RuleId)
.ToDictionary(g => g.Key, g => g.Count())
};
return Results.Ok(stats);
}
private static DeliveryResponse MapToDeliveryResponse(NotifyDelivery delivery)
{
return new DeliveryResponse
{
DeliveryId = delivery.DeliveryId,
TenantId = delivery.TenantId,
RuleId = delivery.RuleId,
ActionId = delivery.ActionId,
EventId = delivery.EventId.ToString(),
Kind = delivery.Kind,
Status = delivery.Status.ToString(),
StatusReason = delivery.StatusReason,
AttemptCount = delivery.Attempts.Length,
LastAttempt = delivery.Attempts.Length > 0 ? delivery.Attempts[^1].Timestamp : null,
CreatedAt = delivery.CreatedAt,
SentAt = delivery.SentAt,
CompletedAt = delivery.CompletedAt,
Metadata = delivery.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
}
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 payloadNode = JsonSerializer.SerializeToNode(payload) as JsonObject;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["entityId"] = entityId,
["entityType"] = entityType,
["payload"] = payloadNode?.ToJsonString() ?? "{}"
};
await audit.AppendAsync(tenantId, action, actor, data, cancellationToken);
}
catch
{
// Ignore audit failures
}
}
private static object Error(string code, string message, HttpContext context) => new
{
error = new
{
code,
message,
traceId = context.TraceIdentifier
}
};
}
/// <summary>
/// Delivery acknowledgment request for v2 API.
/// </summary>
public sealed record DeliveryAckRequest
{
public string? Resolution { get; init; }
public string? Comment { get; init; }
}
/// <summary>
/// Delivery response DTO for v2 API.
/// </summary>
public sealed record DeliveryResponse
{
public required string DeliveryId { get; init; }
public required string TenantId { get; init; }
public required string RuleId { get; init; }
public required string ActionId { get; init; }
public required string EventId { get; init; }
public required string Kind { get; init; }
public required string Status { get; init; }
public string? StatusReason { get; init; }
public required int AttemptCount { get; init; }
public DateTimeOffset? LastAttempt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? SentAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Delivery statistics response for v2 API.
/// </summary>
public sealed record DeliveryStatsResponse
{
public required int Total { get; init; }
public required int Pending { get; init; }
public required int Delivered { get; init; }
public required int Failed { get; init; }
public required Dictionary<string, int> ByKind { get; init; }
public required Dictionary<string, int> ByRule { get; init; }
}

View File

@@ -1,316 +1,316 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// WebSocket live feed for real-time incident updates.
/// </summary>
public static class IncidentLiveFeed
{
private static readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _tenantSubscriptions = new();
public static IEndpointRouteBuilder MapIncidentLiveFeed(this IEndpointRouteBuilder app)
{
app.Map("/api/v2/incidents/live", HandleWebSocketAsync);
return app;
}
private static async Task HandleWebSocketAsync(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
error = new
{
code = "websocket_required",
message = "This endpoint requires a WebSocket connection.",
traceId = context.TraceIdentifier
}
});
return;
}
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
// Try query string fallback for WebSocket clients that can't set headers
tenantId = context.Request.Query["tenant"].ToString();
}
if (string.IsNullOrWhiteSpace(tenantId))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
error = new
{
code = "tenant_missing",
message = "X-StellaOps-Tenant header or 'tenant' query parameter is required.",
traceId = context.TraceIdentifier
}
});
return;
}
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var subscriptions = _tenantSubscriptions.GetOrAdd(tenantId, _ => new ConcurrentBag<WebSocket>());
subscriptions.Add(webSocket);
try
{
// Send connection acknowledgment
var ackMessage = JsonSerializer.Serialize(new
{
type = "connected",
tenantId,
timestamp = DateTimeOffset.UtcNow
});
await SendMessageAsync(webSocket, ackMessage, context.RequestAborted);
// Keep connection alive and handle incoming messages
await ReceiveMessagesAsync(webSocket, tenantId, context.RequestAborted);
}
finally
{
// Remove from subscriptions
var newBag = new ConcurrentBag<WebSocket>(
subscriptions.Where(s => s != webSocket && s.State == WebSocketState.Open));
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
}
}
private static async Task ReceiveMessagesAsync(WebSocket webSocket, string tenantId, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
try
{
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Client initiated close",
cancellationToken);
break;
}
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
await HandleClientMessageAsync(webSocket, tenantId, message, cancellationToken);
}
}
catch (WebSocketException)
{
break;
}
catch (OperationCanceledException)
{
break;
}
}
}
private static async Task HandleClientMessageAsync(WebSocket webSocket, string tenantId, string message, CancellationToken cancellationToken)
{
try
{
using var doc = JsonDocument.Parse(message);
var root = doc.RootElement;
if (root.TryGetProperty("type", out var typeElement))
{
var type = typeElement.GetString();
switch (type)
{
case "ping":
var pongResponse = JsonSerializer.Serialize(new
{
type = "pong",
timestamp = DateTimeOffset.UtcNow
});
await SendMessageAsync(webSocket, pongResponse, cancellationToken);
break;
case "subscribe":
// Handle filter subscriptions (e.g., specific rule IDs, kinds)
var subResponse = JsonSerializer.Serialize(new
{
type = "subscribed",
tenantId,
timestamp = DateTimeOffset.UtcNow
});
await SendMessageAsync(webSocket, subResponse, cancellationToken);
break;
default:
var errorResponse = JsonSerializer.Serialize(new
{
type = "error",
message = $"Unknown message type: {type}"
});
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
break;
}
}
}
catch (JsonException)
{
var errorResponse = JsonSerializer.Serialize(new
{
type = "error",
message = "Invalid JSON message"
});
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
}
}
private static async Task SendMessageAsync(WebSocket webSocket, string message, CancellationToken cancellationToken)
{
if (webSocket.State != WebSocketState.Open)
{
return;
}
var bytes = Encoding.UTF8.GetBytes(message);
await webSocket.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
}
/// <summary>
/// Broadcasts an incident update to all connected clients for the specified tenant.
/// </summary>
public static async Task BroadcastIncidentUpdateAsync(
string tenantId,
NotifyDelivery delivery,
string updateType,
CancellationToken cancellationToken = default)
{
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
{
return;
}
var message = JsonSerializer.Serialize(new
{
type = "incident_update",
updateType, // created, updated, acknowledged, delivered, failed
timestamp = DateTimeOffset.UtcNow,
incident = new
{
deliveryId = delivery.DeliveryId,
tenantId = delivery.TenantId,
ruleId = delivery.RuleId,
actionId = delivery.ActionId,
eventId = delivery.EventId.ToString(),
kind = delivery.Kind,
status = delivery.Status.ToString(),
statusReason = delivery.StatusReason,
attemptCount = delivery.Attempts.Length,
createdAt = delivery.CreatedAt,
sentAt = delivery.SentAt,
completedAt = delivery.CompletedAt
}
});
var deadSockets = new List<WebSocket>();
foreach (var socket in subscriptions)
{
if (socket.State != WebSocketState.Open)
{
deadSockets.Add(socket);
continue;
}
try
{
await SendMessageAsync(socket, message, cancellationToken);
}
catch
{
deadSockets.Add(socket);
}
}
// Clean up dead sockets
if (deadSockets.Count > 0)
{
var newBag = new ConcurrentBag<WebSocket>(
subscriptions.Where(s => !deadSockets.Contains(s)));
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
}
}
/// <summary>
/// Broadcasts incident statistics update to all connected clients for the specified tenant.
/// </summary>
public static async Task BroadcastStatsUpdateAsync(
string tenantId,
int total,
int pending,
int delivered,
int failed,
CancellationToken cancellationToken = default)
{
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
{
return;
}
var message = JsonSerializer.Serialize(new
{
type = "stats_update",
timestamp = DateTimeOffset.UtcNow,
stats = new
{
total,
pending,
delivered,
failed
}
});
foreach (var socket in subscriptions.Where(s => s.State == WebSocketState.Open))
{
try
{
await SendMessageAsync(socket, message, cancellationToken);
}
catch
{
// Ignore send failures
}
}
}
/// <summary>
/// Gets the count of active WebSocket connections for a tenant.
/// </summary>
public static int GetConnectionCount(string tenantId)
{
if (_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
{
return subscriptions.Count(s => s.State == WebSocketState.Open);
}
return 0;
}
}
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// WebSocket live feed for real-time incident updates.
/// </summary>
public static class IncidentLiveFeed
{
private static readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _tenantSubscriptions = new();
public static IEndpointRouteBuilder MapIncidentLiveFeed(this IEndpointRouteBuilder app)
{
app.Map("/api/v2/incidents/live", HandleWebSocketAsync);
return app;
}
private static async Task HandleWebSocketAsync(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
error = new
{
code = "websocket_required",
message = "This endpoint requires a WebSocket connection.",
traceId = context.TraceIdentifier
}
});
return;
}
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
// Try query string fallback for WebSocket clients that can't set headers
tenantId = context.Request.Query["tenant"].ToString();
}
if (string.IsNullOrWhiteSpace(tenantId))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
error = new
{
code = "tenant_missing",
message = "X-StellaOps-Tenant header or 'tenant' query parameter is required.",
traceId = context.TraceIdentifier
}
});
return;
}
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var subscriptions = _tenantSubscriptions.GetOrAdd(tenantId, _ => new ConcurrentBag<WebSocket>());
subscriptions.Add(webSocket);
try
{
// Send connection acknowledgment
var ackMessage = JsonSerializer.Serialize(new
{
type = "connected",
tenantId,
timestamp = DateTimeOffset.UtcNow
});
await SendMessageAsync(webSocket, ackMessage, context.RequestAborted);
// Keep connection alive and handle incoming messages
await ReceiveMessagesAsync(webSocket, tenantId, context.RequestAborted);
}
finally
{
// Remove from subscriptions
var newBag = new ConcurrentBag<WebSocket>(
subscriptions.Where(s => s != webSocket && s.State == WebSocketState.Open));
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
}
}
private static async Task ReceiveMessagesAsync(WebSocket webSocket, string tenantId, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
try
{
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Client initiated close",
cancellationToken);
break;
}
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
await HandleClientMessageAsync(webSocket, tenantId, message, cancellationToken);
}
}
catch (WebSocketException)
{
break;
}
catch (OperationCanceledException)
{
break;
}
}
}
private static async Task HandleClientMessageAsync(WebSocket webSocket, string tenantId, string message, CancellationToken cancellationToken)
{
try
{
using var doc = JsonDocument.Parse(message);
var root = doc.RootElement;
if (root.TryGetProperty("type", out var typeElement))
{
var type = typeElement.GetString();
switch (type)
{
case "ping":
var pongResponse = JsonSerializer.Serialize(new
{
type = "pong",
timestamp = DateTimeOffset.UtcNow
});
await SendMessageAsync(webSocket, pongResponse, cancellationToken);
break;
case "subscribe":
// Handle filter subscriptions (e.g., specific rule IDs, kinds)
var subResponse = JsonSerializer.Serialize(new
{
type = "subscribed",
tenantId,
timestamp = DateTimeOffset.UtcNow
});
await SendMessageAsync(webSocket, subResponse, cancellationToken);
break;
default:
var errorResponse = JsonSerializer.Serialize(new
{
type = "error",
message = $"Unknown message type: {type}"
});
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
break;
}
}
}
catch (JsonException)
{
var errorResponse = JsonSerializer.Serialize(new
{
type = "error",
message = "Invalid JSON message"
});
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
}
}
private static async Task SendMessageAsync(WebSocket webSocket, string message, CancellationToken cancellationToken)
{
if (webSocket.State != WebSocketState.Open)
{
return;
}
var bytes = Encoding.UTF8.GetBytes(message);
await webSocket.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken);
}
/// <summary>
/// Broadcasts an incident update to all connected clients for the specified tenant.
/// </summary>
public static async Task BroadcastIncidentUpdateAsync(
string tenantId,
NotifyDelivery delivery,
string updateType,
CancellationToken cancellationToken = default)
{
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
{
return;
}
var message = JsonSerializer.Serialize(new
{
type = "incident_update",
updateType, // created, updated, acknowledged, delivered, failed
timestamp = DateTimeOffset.UtcNow,
incident = new
{
deliveryId = delivery.DeliveryId,
tenantId = delivery.TenantId,
ruleId = delivery.RuleId,
actionId = delivery.ActionId,
eventId = delivery.EventId.ToString(),
kind = delivery.Kind,
status = delivery.Status.ToString(),
statusReason = delivery.StatusReason,
attemptCount = delivery.Attempts.Length,
createdAt = delivery.CreatedAt,
sentAt = delivery.SentAt,
completedAt = delivery.CompletedAt
}
});
var deadSockets = new List<WebSocket>();
foreach (var socket in subscriptions)
{
if (socket.State != WebSocketState.Open)
{
deadSockets.Add(socket);
continue;
}
try
{
await SendMessageAsync(socket, message, cancellationToken);
}
catch
{
deadSockets.Add(socket);
}
}
// Clean up dead sockets
if (deadSockets.Count > 0)
{
var newBag = new ConcurrentBag<WebSocket>(
subscriptions.Where(s => !deadSockets.Contains(s)));
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
}
}
/// <summary>
/// Broadcasts incident statistics update to all connected clients for the specified tenant.
/// </summary>
public static async Task BroadcastStatsUpdateAsync(
string tenantId,
int total,
int pending,
int delivered,
int failed,
CancellationToken cancellationToken = default)
{
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
{
return;
}
var message = JsonSerializer.Serialize(new
{
type = "stats_update",
timestamp = DateTimeOffset.UtcNow,
stats = new
{
total,
pending,
delivered,
failed
}
});
foreach (var socket in subscriptions.Where(s => s.State == WebSocketState.Open))
{
try
{
await SendMessageAsync(socket, message, cancellationToken);
}
catch
{
// Ignore send failures
}
}
}
/// <summary>
/// Gets the count of active WebSocket connections for a tenant.
/// </summary>
public static int GetConnectionCount(string tenantId)
{
if (_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
{
return subscriptions.Count(s => s.State == WebSocketState.Open);
}
return 0;
}
}

View File

@@ -1,306 +1,306 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.Worker.Localization;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST API endpoints for localization operations.
/// </summary>
public static class LocalizationEndpoints
{
/// <summary>
/// Maps localization API endpoints.
/// </summary>
public static RouteGroupBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/localization")
.WithTags("Localization")
.WithOpenApi();
// List bundles
group.MapGet("/bundles", async (
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var bundles = await localizationService.ListBundlesAsync(tenantId, cancellationToken);
return Results.Ok(new
{
tenantId,
bundles = bundles.Select(b => new
{
b.BundleId,
b.TenantId,
b.Locale,
b.Namespace,
stringCount = b.Strings.Count,
b.Priority,
b.Enabled,
b.Source,
b.Description,
b.CreatedAt,
b.UpdatedAt
}).ToList(),
count = bundles.Count
});
})
.WithName("ListLocalizationBundles")
.WithSummary("Lists all localization bundles for a tenant");
// Get supported locales
group.MapGet("/locales", async (
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var locales = await localizationService.GetSupportedLocalesAsync(tenantId, cancellationToken);
return Results.Ok(new
{
tenantId,
locales,
count = locales.Count
});
})
.WithName("GetSupportedLocales")
.WithSummary("Gets all supported locales for a tenant");
// Get bundle contents
group.MapGet("/bundles/{locale}", async (
string locale,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var strings = await localizationService.GetBundleAsync(tenantId, locale, cancellationToken);
return Results.Ok(new
{
tenantId,
locale,
strings,
count = strings.Count
});
})
.WithName("GetLocalizationBundle")
.WithSummary("Gets all localized strings for a locale");
// Get single string
group.MapGet("/strings/{key}", async (
string key,
string? locale,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var effectiveLocale = locale ?? "en-US";
var value = await localizationService.GetStringAsync(tenantId, key, effectiveLocale, cancellationToken);
return Results.Ok(new
{
tenantId,
key,
locale = effectiveLocale,
value
});
})
.WithName("GetLocalizedString")
.WithSummary("Gets a single localized string");
// Format string with parameters
group.MapPost("/strings/{key}/format", async (
string key,
FormatStringRequest request,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var locale = request.Locale ?? "en-US";
var parameters = request.Parameters ?? new Dictionary<string, object>();
var value = await localizationService.GetFormattedStringAsync(
tenantId, key, locale, parameters, cancellationToken);
return Results.Ok(new
{
tenantId,
key,
locale,
formatted = value
});
})
.WithName("FormatLocalizedString")
.WithSummary("Gets a localized string with parameter substitution");
// Create/update bundle
group.MapPut("/bundles", async (
CreateBundleRequest request,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
var bundle = new LocalizationBundle
{
BundleId = request.BundleId ?? $"bundle-{Guid.NewGuid():N}"[..20],
TenantId = tenantId,
Locale = request.Locale,
Namespace = request.Namespace ?? "default",
Strings = request.Strings,
Priority = request.Priority,
Enabled = request.Enabled,
Description = request.Description,
Source = "api"
};
var result = await localizationService.UpsertBundleAsync(bundle, actor, cancellationToken);
if (!result.Success)
{
return Results.BadRequest(new { error = result.Error });
}
return result.IsNew
? Results.Created($"/api/v2/localization/bundles/{bundle.Locale}", new
{
bundleId = result.BundleId,
message = "Bundle created successfully"
})
: Results.Ok(new
{
bundleId = result.BundleId,
message = "Bundle updated successfully"
});
})
.WithName("UpsertLocalizationBundle")
.WithSummary("Creates or updates a localization bundle");
// Delete bundle
group.MapDelete("/bundles/{bundleId}", async (
string bundleId,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
var deleted = await localizationService.DeleteBundleAsync(tenantId, bundleId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = $"Bundle '{bundleId}' not found" });
}
return Results.Ok(new { message = $"Bundle '{bundleId}' deleted successfully" });
})
.WithName("DeleteLocalizationBundle")
.WithSummary("Deletes a localization bundle");
// Validate bundle
group.MapPost("/bundles/validate", (
CreateBundleRequest request,
HttpContext context,
ILocalizationService localizationService) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var bundle = new LocalizationBundle
{
BundleId = request.BundleId ?? "validation",
TenantId = tenantId,
Locale = request.Locale,
Namespace = request.Namespace ?? "default",
Strings = request.Strings,
Priority = request.Priority,
Enabled = request.Enabled,
Description = request.Description
};
var result = localizationService.Validate(bundle);
return Results.Ok(new
{
result.IsValid,
result.Errors,
result.Warnings
});
})
.WithName("ValidateLocalizationBundle")
.WithSummary("Validates a localization bundle without saving");
return group;
}
}
/// <summary>
/// Request to format a localized string.
/// </summary>
public sealed record FormatStringRequest
{
/// <summary>
/// Target locale.
/// </summary>
public string? Locale { get; init; }
/// <summary>
/// Parameters for substitution.
/// </summary>
public Dictionary<string, object>? Parameters { get; init; }
}
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record CreateBundleRequest
{
/// <summary>
/// Bundle ID (auto-generated if not provided).
/// </summary>
public string? BundleId { get; init; }
/// <summary>
/// Locale code.
/// </summary>
public required string Locale { get; init; }
/// <summary>
/// Namespace/category.
/// </summary>
public string? Namespace { get; init; }
/// <summary>
/// Localized strings.
/// </summary>
public required Dictionary<string, string> Strings { get; init; }
/// <summary>
/// Bundle priority.
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Whether bundle is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Bundle description.
/// </summary>
public string? Description { get; init; }
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.Worker.Localization;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST API endpoints for localization operations.
/// </summary>
public static class LocalizationEndpoints
{
/// <summary>
/// Maps localization API endpoints.
/// </summary>
public static RouteGroupBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/localization")
.WithTags("Localization")
.WithOpenApi();
// List bundles
group.MapGet("/bundles", async (
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var bundles = await localizationService.ListBundlesAsync(tenantId, cancellationToken);
return Results.Ok(new
{
tenantId,
bundles = bundles.Select(b => new
{
b.BundleId,
b.TenantId,
b.Locale,
b.Namespace,
stringCount = b.Strings.Count,
b.Priority,
b.Enabled,
b.Source,
b.Description,
b.CreatedAt,
b.UpdatedAt
}).ToList(),
count = bundles.Count
});
})
.WithName("ListLocalizationBundles")
.WithSummary("Lists all localization bundles for a tenant");
// Get supported locales
group.MapGet("/locales", async (
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var locales = await localizationService.GetSupportedLocalesAsync(tenantId, cancellationToken);
return Results.Ok(new
{
tenantId,
locales,
count = locales.Count
});
})
.WithName("GetSupportedLocales")
.WithSummary("Gets all supported locales for a tenant");
// Get bundle contents
group.MapGet("/bundles/{locale}", async (
string locale,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var strings = await localizationService.GetBundleAsync(tenantId, locale, cancellationToken);
return Results.Ok(new
{
tenantId,
locale,
strings,
count = strings.Count
});
})
.WithName("GetLocalizationBundle")
.WithSummary("Gets all localized strings for a locale");
// Get single string
group.MapGet("/strings/{key}", async (
string key,
string? locale,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var effectiveLocale = locale ?? "en-US";
var value = await localizationService.GetStringAsync(tenantId, key, effectiveLocale, cancellationToken);
return Results.Ok(new
{
tenantId,
key,
locale = effectiveLocale,
value
});
})
.WithName("GetLocalizedString")
.WithSummary("Gets a single localized string");
// Format string with parameters
group.MapPost("/strings/{key}/format", async (
string key,
FormatStringRequest request,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var locale = request.Locale ?? "en-US";
var parameters = request.Parameters ?? new Dictionary<string, object>();
var value = await localizationService.GetFormattedStringAsync(
tenantId, key, locale, parameters, cancellationToken);
return Results.Ok(new
{
tenantId,
key,
locale,
formatted = value
});
})
.WithName("FormatLocalizedString")
.WithSummary("Gets a localized string with parameter substitution");
// Create/update bundle
group.MapPut("/bundles", async (
CreateBundleRequest request,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
var bundle = new LocalizationBundle
{
BundleId = request.BundleId ?? $"bundle-{Guid.NewGuid():N}"[..20],
TenantId = tenantId,
Locale = request.Locale,
Namespace = request.Namespace ?? "default",
Strings = request.Strings,
Priority = request.Priority,
Enabled = request.Enabled,
Description = request.Description,
Source = "api"
};
var result = await localizationService.UpsertBundleAsync(bundle, actor, cancellationToken);
if (!result.Success)
{
return Results.BadRequest(new { error = result.Error });
}
return result.IsNew
? Results.Created($"/api/v2/localization/bundles/{bundle.Locale}", new
{
bundleId = result.BundleId,
message = "Bundle created successfully"
})
: Results.Ok(new
{
bundleId = result.BundleId,
message = "Bundle updated successfully"
});
})
.WithName("UpsertLocalizationBundle")
.WithSummary("Creates or updates a localization bundle");
// Delete bundle
group.MapDelete("/bundles/{bundleId}", async (
string bundleId,
HttpContext context,
ILocalizationService localizationService,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
var deleted = await localizationService.DeleteBundleAsync(tenantId, bundleId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = $"Bundle '{bundleId}' not found" });
}
return Results.Ok(new { message = $"Bundle '{bundleId}' deleted successfully" });
})
.WithName("DeleteLocalizationBundle")
.WithSummary("Deletes a localization bundle");
// Validate bundle
group.MapPost("/bundles/validate", (
CreateBundleRequest request,
HttpContext context,
ILocalizationService localizationService) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var bundle = new LocalizationBundle
{
BundleId = request.BundleId ?? "validation",
TenantId = tenantId,
Locale = request.Locale,
Namespace = request.Namespace ?? "default",
Strings = request.Strings,
Priority = request.Priority,
Enabled = request.Enabled,
Description = request.Description
};
var result = localizationService.Validate(bundle);
return Results.Ok(new
{
result.IsValid,
result.Errors,
result.Warnings
});
})
.WithName("ValidateLocalizationBundle")
.WithSummary("Validates a localization bundle without saving");
return group;
}
}
/// <summary>
/// Request to format a localized string.
/// </summary>
public sealed record FormatStringRequest
{
/// <summary>
/// Target locale.
/// </summary>
public string? Locale { get; init; }
/// <summary>
/// Parameters for substitution.
/// </summary>
public Dictionary<string, object>? Parameters { get; init; }
}
/// <summary>
/// Request to create/update a localization bundle.
/// </summary>
public sealed record CreateBundleRequest
{
/// <summary>
/// Bundle ID (auto-generated if not provided).
/// </summary>
public string? BundleId { get; init; }
/// <summary>
/// Locale code.
/// </summary>
public required string Locale { get; init; }
/// <summary>
/// Namespace/category.
/// </summary>
public string? Namespace { get; init; }
/// <summary>
/// Localized strings.
/// </summary>
public required Dictionary<string, string> Strings { get; init; }
/// <summary>
/// Bundle priority.
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Whether bundle is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Bundle description.
/// </summary>
public string? Description { get; init; }
}

View File

@@ -1,313 +1,313 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for operator override management.
/// </summary>
public static class OperatorOverrideEndpoints
{
/// <summary>
/// Maps operator override endpoints.
/// </summary>
public static IEndpointRouteBuilder MapOperatorOverrideEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/overrides")
.WithTags("Overrides")
.WithOpenApi();
group.MapGet("/", ListOverridesAsync)
.WithName("ListOperatorOverrides")
.WithSummary("List active operator overrides")
.WithDescription("Returns all active operator overrides for the tenant.");
group.MapGet("/{overrideId}", GetOverrideAsync)
.WithName("GetOperatorOverride")
.WithSummary("Get an operator override")
.WithDescription("Returns a specific operator override by ID.");
group.MapPost("/", CreateOverrideAsync)
.WithName("CreateOperatorOverride")
.WithSummary("Create an operator override")
.WithDescription("Creates a new operator override to bypass quiet hours and/or throttling.");
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
.WithName("RevokeOperatorOverride")
.WithSummary("Revoke an operator override")
.WithDescription("Revokes an active operator override.");
group.MapPost("/check", CheckOverrideAsync)
.WithName("CheckOperatorOverride")
.WithSummary("Check for applicable override")
.WithDescription("Checks if an override applies to the given event criteria.");
return app;
}
private static async Task<IResult> ListOverridesAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
return Results.Ok(overrides.Select(MapToApiResponse));
}
private static async Task<IResult> GetOverrideAsync(
string overrideId,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
if (@override is null)
{
return Results.NotFound(new { error = $"Override '{overrideId}' not found." });
}
return Results.Ok(MapToApiResponse(@override));
}
private static async Task<IResult> CreateOverrideAsync(
[FromBody] OperatorOverrideApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actorHeader,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
var actor = request.Actor ?? actorHeader;
if (string.IsNullOrWhiteSpace(actor))
{
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
}
if (string.IsNullOrWhiteSpace(request.Reason))
{
return Results.BadRequest(new { error = "Reason is required." });
}
if (request.DurationMinutes is null or <= 0)
{
return Results.BadRequest(new { error = "Duration must be a positive value in minutes." });
}
var createRequest = new OperatorOverrideCreate
{
Type = MapOverrideType(request.Type),
Reason = request.Reason,
Duration = TimeSpan.FromMinutes(request.DurationMinutes.Value),
EffectiveFrom = request.EffectiveFrom,
EventKinds = request.EventKinds,
CorrelationKeys = request.CorrelationKeys,
MaxUsageCount = request.MaxUsageCount
};
try
{
var created = await overrideService.CreateOverrideAsync(tenantId, createRequest, actor, cancellationToken);
return Results.Created($"/api/v2/overrides/{created.OverrideId}", MapToApiResponse(created));
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return Results.Conflict(new { error = ex.Message });
}
}
private static async Task<IResult> RevokeOverrideAsync(
string overrideId,
[FromBody] RevokeOverrideApiRequest? request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-Actor")] string? actorHeader,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var actor = request?.Actor ?? actorHeader;
if (string.IsNullOrWhiteSpace(actor))
{
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
}
var revoked = await overrideService.RevokeOverrideAsync(
tenantId,
overrideId,
actor,
request?.Reason,
cancellationToken);
if (!revoked)
{
return Results.NotFound(new { error = $"Override '{overrideId}' not found or already inactive." });
}
return Results.NoContent();
}
private static async Task<IResult> CheckOverrideAsync(
[FromBody] CheckOverrideApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
}
var result = await overrideService.CheckOverrideAsync(
tenantId,
request.EventKind,
request.CorrelationKey,
cancellationToken);
return Results.Ok(new CheckOverrideApiResponse
{
HasOverride = result.HasOverride,
BypassedTypes = MapOverrideTypeToStrings(result.BypassedTypes),
Override = result.Override is not null ? MapToApiResponse(result.Override) : null
});
}
private static OverrideType MapOverrideType(string? type) => type?.ToLowerInvariant() switch
{
"quiethours" or "quiet_hours" => OverrideType.QuietHours,
"throttle" => OverrideType.Throttle,
"maintenance" => OverrideType.Maintenance,
"all" or _ => OverrideType.All
};
private static List<string> MapOverrideTypeToStrings(OverrideType type)
{
var result = new List<string>();
if (type.HasFlag(OverrideType.QuietHours)) result.Add("quiet_hours");
if (type.HasFlag(OverrideType.Throttle)) result.Add("throttle");
if (type.HasFlag(OverrideType.Maintenance)) result.Add("maintenance");
return result;
}
private static OperatorOverrideApiResponse MapToApiResponse(OperatorOverride @override) => new()
{
OverrideId = @override.OverrideId,
TenantId = @override.TenantId,
Type = MapOverrideTypeToStrings(@override.Type),
Reason = @override.Reason,
EffectiveFrom = @override.EffectiveFrom,
ExpiresAt = @override.ExpiresAt,
EventKinds = @override.EventKinds.ToList(),
CorrelationKeys = @override.CorrelationKeys.ToList(),
MaxUsageCount = @override.MaxUsageCount,
UsageCount = @override.UsageCount,
Status = @override.Status.ToString().ToLowerInvariant(),
CreatedBy = @override.CreatedBy,
CreatedAt = @override.CreatedAt,
RevokedBy = @override.RevokedBy,
RevokedAt = @override.RevokedAt,
RevocationReason = @override.RevocationReason
};
}
#region API Request/Response Models
/// <summary>
/// Request to create an operator override.
/// </summary>
public sealed class OperatorOverrideApiRequest
{
public string? TenantId { get; set; }
public string? Actor { get; set; }
public string? Type { get; set; }
public string? Reason { get; set; }
public int? DurationMinutes { get; set; }
public DateTimeOffset? EffectiveFrom { get; set; }
public List<string>? EventKinds { get; set; }
public List<string>? CorrelationKeys { get; set; }
public int? MaxUsageCount { get; set; }
}
/// <summary>
/// Request to revoke an operator override.
/// </summary>
public sealed class RevokeOverrideApiRequest
{
public string? Actor { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Request to check for applicable override.
/// </summary>
public sealed class CheckOverrideApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
public string? CorrelationKey { get; set; }
}
/// <summary>
/// Response for an operator override.
/// </summary>
public sealed class OperatorOverrideApiResponse
{
public required string OverrideId { get; set; }
public required string TenantId { get; set; }
public required List<string> Type { get; set; }
public required string Reason { get; set; }
public required DateTimeOffset EffectiveFrom { get; set; }
public required DateTimeOffset ExpiresAt { get; set; }
public required List<string> EventKinds { get; set; }
public required List<string> CorrelationKeys { get; set; }
public int? MaxUsageCount { get; set; }
public required int UsageCount { get; set; }
public required string Status { get; set; }
public required string CreatedBy { get; set; }
public required DateTimeOffset CreatedAt { get; set; }
public string? RevokedBy { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public string? RevocationReason { get; set; }
}
/// <summary>
/// Response for override check.
/// </summary>
public sealed class CheckOverrideApiResponse
{
public required bool HasOverride { get; set; }
public required List<string> BypassedTypes { get; set; }
public OperatorOverrideApiResponse? Override { get; set; }
}
#endregion
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for operator override management.
/// </summary>
public static class OperatorOverrideEndpoints
{
/// <summary>
/// Maps operator override endpoints.
/// </summary>
public static IEndpointRouteBuilder MapOperatorOverrideEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/overrides")
.WithTags("Overrides")
.WithOpenApi();
group.MapGet("/", ListOverridesAsync)
.WithName("ListOperatorOverrides")
.WithSummary("List active operator overrides")
.WithDescription("Returns all active operator overrides for the tenant.");
group.MapGet("/{overrideId}", GetOverrideAsync)
.WithName("GetOperatorOverride")
.WithSummary("Get an operator override")
.WithDescription("Returns a specific operator override by ID.");
group.MapPost("/", CreateOverrideAsync)
.WithName("CreateOperatorOverride")
.WithSummary("Create an operator override")
.WithDescription("Creates a new operator override to bypass quiet hours and/or throttling.");
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
.WithName("RevokeOperatorOverride")
.WithSummary("Revoke an operator override")
.WithDescription("Revokes an active operator override.");
group.MapPost("/check", CheckOverrideAsync)
.WithName("CheckOperatorOverride")
.WithSummary("Check for applicable override")
.WithDescription("Checks if an override applies to the given event criteria.");
return app;
}
private static async Task<IResult> ListOverridesAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
return Results.Ok(overrides.Select(MapToApiResponse));
}
private static async Task<IResult> GetOverrideAsync(
string overrideId,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
if (@override is null)
{
return Results.NotFound(new { error = $"Override '{overrideId}' not found." });
}
return Results.Ok(MapToApiResponse(@override));
}
private static async Task<IResult> CreateOverrideAsync(
[FromBody] OperatorOverrideApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actorHeader,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
var actor = request.Actor ?? actorHeader;
if (string.IsNullOrWhiteSpace(actor))
{
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
}
if (string.IsNullOrWhiteSpace(request.Reason))
{
return Results.BadRequest(new { error = "Reason is required." });
}
if (request.DurationMinutes is null or <= 0)
{
return Results.BadRequest(new { error = "Duration must be a positive value in minutes." });
}
var createRequest = new OperatorOverrideCreate
{
Type = MapOverrideType(request.Type),
Reason = request.Reason,
Duration = TimeSpan.FromMinutes(request.DurationMinutes.Value),
EffectiveFrom = request.EffectiveFrom,
EventKinds = request.EventKinds,
CorrelationKeys = request.CorrelationKeys,
MaxUsageCount = request.MaxUsageCount
};
try
{
var created = await overrideService.CreateOverrideAsync(tenantId, createRequest, actor, cancellationToken);
return Results.Created($"/api/v2/overrides/{created.OverrideId}", MapToApiResponse(created));
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return Results.Conflict(new { error = ex.Message });
}
}
private static async Task<IResult> RevokeOverrideAsync(
string overrideId,
[FromBody] RevokeOverrideApiRequest? request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-Actor")] string? actorHeader,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var actor = request?.Actor ?? actorHeader;
if (string.IsNullOrWhiteSpace(actor))
{
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
}
var revoked = await overrideService.RevokeOverrideAsync(
tenantId,
overrideId,
actor,
request?.Reason,
cancellationToken);
if (!revoked)
{
return Results.NotFound(new { error = $"Override '{overrideId}' not found or already inactive." });
}
return Results.NoContent();
}
private static async Task<IResult> CheckOverrideAsync(
[FromBody] CheckOverrideApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] IOperatorOverrideService overrideService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
}
var result = await overrideService.CheckOverrideAsync(
tenantId,
request.EventKind,
request.CorrelationKey,
cancellationToken);
return Results.Ok(new CheckOverrideApiResponse
{
HasOverride = result.HasOverride,
BypassedTypes = MapOverrideTypeToStrings(result.BypassedTypes),
Override = result.Override is not null ? MapToApiResponse(result.Override) : null
});
}
private static OverrideType MapOverrideType(string? type) => type?.ToLowerInvariant() switch
{
"quiethours" or "quiet_hours" => OverrideType.QuietHours,
"throttle" => OverrideType.Throttle,
"maintenance" => OverrideType.Maintenance,
"all" or _ => OverrideType.All
};
private static List<string> MapOverrideTypeToStrings(OverrideType type)
{
var result = new List<string>();
if (type.HasFlag(OverrideType.QuietHours)) result.Add("quiet_hours");
if (type.HasFlag(OverrideType.Throttle)) result.Add("throttle");
if (type.HasFlag(OverrideType.Maintenance)) result.Add("maintenance");
return result;
}
private static OperatorOverrideApiResponse MapToApiResponse(OperatorOverride @override) => new()
{
OverrideId = @override.OverrideId,
TenantId = @override.TenantId,
Type = MapOverrideTypeToStrings(@override.Type),
Reason = @override.Reason,
EffectiveFrom = @override.EffectiveFrom,
ExpiresAt = @override.ExpiresAt,
EventKinds = @override.EventKinds.ToList(),
CorrelationKeys = @override.CorrelationKeys.ToList(),
MaxUsageCount = @override.MaxUsageCount,
UsageCount = @override.UsageCount,
Status = @override.Status.ToString().ToLowerInvariant(),
CreatedBy = @override.CreatedBy,
CreatedAt = @override.CreatedAt,
RevokedBy = @override.RevokedBy,
RevokedAt = @override.RevokedAt,
RevocationReason = @override.RevocationReason
};
}
#region API Request/Response Models
/// <summary>
/// Request to create an operator override.
/// </summary>
public sealed class OperatorOverrideApiRequest
{
public string? TenantId { get; set; }
public string? Actor { get; set; }
public string? Type { get; set; }
public string? Reason { get; set; }
public int? DurationMinutes { get; set; }
public DateTimeOffset? EffectiveFrom { get; set; }
public List<string>? EventKinds { get; set; }
public List<string>? CorrelationKeys { get; set; }
public int? MaxUsageCount { get; set; }
}
/// <summary>
/// Request to revoke an operator override.
/// </summary>
public sealed class RevokeOverrideApiRequest
{
public string? Actor { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Request to check for applicable override.
/// </summary>
public sealed class CheckOverrideApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
public string? CorrelationKey { get; set; }
}
/// <summary>
/// Response for an operator override.
/// </summary>
public sealed class OperatorOverrideApiResponse
{
public required string OverrideId { get; set; }
public required string TenantId { get; set; }
public required List<string> Type { get; set; }
public required string Reason { get; set; }
public required DateTimeOffset EffectiveFrom { get; set; }
public required DateTimeOffset ExpiresAt { get; set; }
public required List<string> EventKinds { get; set; }
public required List<string> CorrelationKeys { get; set; }
public int? MaxUsageCount { get; set; }
public required int UsageCount { get; set; }
public required string Status { get; set; }
public required string CreatedBy { get; set; }
public required DateTimeOffset CreatedAt { get; set; }
public string? RevokedBy { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public string? RevocationReason { get; set; }
}
/// <summary>
/// Response for override check.
/// </summary>
public sealed class CheckOverrideApiResponse
{
public required bool HasOverride { get; set; }
public required List<string> BypassedTypes { get; set; }
public OperatorOverrideApiResponse? Override { get; set; }
}
#endregion

View File

@@ -1,352 +1,352 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for quiet hours calendar management.
/// </summary>
public static class QuietHoursEndpoints
{
/// <summary>
/// Maps quiet hours endpoints.
/// </summary>
public static IEndpointRouteBuilder MapQuietHoursEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/quiet-hours")
.WithTags("QuietHours")
.WithOpenApi();
group.MapGet("/calendars", ListCalendarsAsync)
.WithName("ListQuietHoursCalendars")
.WithSummary("List all quiet hours calendars")
.WithDescription("Returns all quiet hours calendars for the tenant.");
group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
.WithName("GetQuietHoursCalendar")
.WithSummary("Get a quiet hours calendar")
.WithDescription("Returns a specific quiet hours calendar by ID.");
group.MapPost("/calendars", CreateCalendarAsync)
.WithName("CreateQuietHoursCalendar")
.WithSummary("Create a quiet hours calendar")
.WithDescription("Creates a new quiet hours calendar with schedules.");
group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
.WithName("UpdateQuietHoursCalendar")
.WithSummary("Update a quiet hours calendar")
.WithDescription("Updates an existing quiet hours calendar.");
group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
.WithName("DeleteQuietHoursCalendar")
.WithSummary("Delete a quiet hours calendar")
.WithDescription("Deletes a quiet hours calendar.");
group.MapPost("/evaluate", EvaluateAsync)
.WithName("EvaluateQuietHours")
.WithSummary("Evaluate quiet hours")
.WithDescription("Checks if quiet hours are currently active for an event kind.");
return app;
}
private static async Task<IResult> ListCalendarsAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var calendars = await calendarService.ListCalendarsAsync(tenantId, cancellationToken);
return Results.Ok(calendars.Select(MapToApiResponse));
}
private static async Task<IResult> GetCalendarAsync(
string calendarId,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var calendar = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
if (calendar is null)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
}
return Results.Ok(MapToApiResponse(calendar));
}
private static async Task<IResult> CreateCalendarAsync(
[FromBody] QuietHoursCalendarApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Calendar name is required." });
}
if (request.Schedules is null || request.Schedules.Count == 0)
{
return Results.BadRequest(new { error = "At least one schedule is required." });
}
var calendarId = request.CalendarId ?? Guid.NewGuid().ToString("N")[..16];
var calendar = new QuietHoursCalendar
{
CalendarId = calendarId,
TenantId = tenantId,
Name = request.Name,
Description = request.Description,
Enabled = request.Enabled ?? true,
Priority = request.Priority ?? 100,
Schedules = request.Schedules.Select(MapToScheduleEntry).ToList(),
ExcludedEventKinds = request.ExcludedEventKinds,
IncludedEventKinds = request.IncludedEventKinds
};
var created = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
return Results.Created($"/api/v2/quiet-hours/calendars/{created.CalendarId}", MapToApiResponse(created));
}
private static async Task<IResult> UpdateCalendarAsync(
string calendarId,
[FromBody] QuietHoursCalendarApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
var existing = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
}
var calendar = new QuietHoursCalendar
{
CalendarId = calendarId,
TenantId = tenantId,
Name = request.Name ?? existing.Name,
Description = request.Description ?? existing.Description,
Enabled = request.Enabled ?? existing.Enabled,
Priority = request.Priority ?? existing.Priority,
Schedules = request.Schedules?.Select(MapToScheduleEntry).ToList() ?? existing.Schedules,
ExcludedEventKinds = request.ExcludedEventKinds ?? existing.ExcludedEventKinds,
IncludedEventKinds = request.IncludedEventKinds ?? existing.IncludedEventKinds,
CreatedAt = existing.CreatedAt,
CreatedBy = existing.CreatedBy
};
var updated = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
return Results.Ok(MapToApiResponse(updated));
}
private static async Task<IResult> DeleteCalendarAsync(
string calendarId,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var deleted = await calendarService.DeleteCalendarAsync(tenantId, calendarId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
}
return Results.NoContent();
}
private static async Task<IResult> EvaluateAsync(
[FromBody] QuietHoursEvaluateApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
}
var result = await calendarService.EvaluateAsync(
tenantId,
request.EventKind,
request.EvaluationTime,
cancellationToken);
return Results.Ok(new QuietHoursEvaluateApiResponse
{
IsActive = result.IsActive,
MatchedCalendarId = result.MatchedCalendarId,
MatchedCalendarName = result.MatchedCalendarName,
MatchedScheduleName = result.MatchedScheduleName,
EndsAt = result.EndsAt,
Reason = result.Reason
});
}
private static QuietHoursScheduleEntry MapToScheduleEntry(QuietHoursScheduleApiRequest request) => new()
{
Name = request.Name ?? "Unnamed Schedule",
StartTime = request.StartTime ?? "00:00",
EndTime = request.EndTime ?? "00:00",
DaysOfWeek = request.DaysOfWeek,
Timezone = request.Timezone,
Enabled = request.Enabled ?? true
};
private static QuietHoursCalendarApiResponse MapToApiResponse(QuietHoursCalendar calendar) => new()
{
CalendarId = calendar.CalendarId,
TenantId = calendar.TenantId,
Name = calendar.Name,
Description = calendar.Description,
Enabled = calendar.Enabled,
Priority = calendar.Priority,
Schedules = calendar.Schedules.Select(s => new QuietHoursScheduleApiResponse
{
Name = s.Name,
StartTime = s.StartTime,
EndTime = s.EndTime,
DaysOfWeek = s.DaysOfWeek?.ToList(),
Timezone = s.Timezone,
Enabled = s.Enabled
}).ToList(),
ExcludedEventKinds = calendar.ExcludedEventKinds?.ToList(),
IncludedEventKinds = calendar.IncludedEventKinds?.ToList(),
CreatedAt = calendar.CreatedAt,
CreatedBy = calendar.CreatedBy,
UpdatedAt = calendar.UpdatedAt,
UpdatedBy = calendar.UpdatedBy
};
}
#region API Request/Response Models
/// <summary>
/// Request to create or update a quiet hours calendar.
/// </summary>
public sealed class QuietHoursCalendarApiRequest
{
public string? CalendarId { get; set; }
public string? TenantId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public bool? Enabled { get; set; }
public int? Priority { get; set; }
public List<QuietHoursScheduleApiRequest>? Schedules { get; set; }
public List<string>? ExcludedEventKinds { get; set; }
public List<string>? IncludedEventKinds { get; set; }
}
/// <summary>
/// Schedule entry in a quiet hours calendar request.
/// </summary>
public sealed class QuietHoursScheduleApiRequest
{
public string? Name { get; set; }
public string? StartTime { get; set; }
public string? EndTime { get; set; }
public List<int>? DaysOfWeek { get; set; }
public string? Timezone { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Request to evaluate quiet hours.
/// </summary>
public sealed class QuietHoursEvaluateApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
public DateTimeOffset? EvaluationTime { get; set; }
}
/// <summary>
/// Response for a quiet hours calendar.
/// </summary>
public sealed class QuietHoursCalendarApiResponse
{
public required string CalendarId { get; set; }
public required string TenantId { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public required bool Enabled { get; set; }
public required int Priority { get; set; }
public required List<QuietHoursScheduleApiResponse> Schedules { get; set; }
public List<string>? ExcludedEventKinds { get; set; }
public List<string>? IncludedEventKinds { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
/// <summary>
/// Schedule entry in a quiet hours calendar response.
/// </summary>
public sealed class QuietHoursScheduleApiResponse
{
public required string Name { get; set; }
public required string StartTime { get; set; }
public required string EndTime { get; set; }
public List<int>? DaysOfWeek { get; set; }
public string? Timezone { get; set; }
public required bool Enabled { get; set; }
}
/// <summary>
/// Response for quiet hours evaluation.
/// </summary>
public sealed class QuietHoursEvaluateApiResponse
{
public required bool IsActive { get; set; }
public string? MatchedCalendarId { get; set; }
public string? MatchedCalendarName { get; set; }
public string? MatchedScheduleName { get; set; }
public DateTimeOffset? EndsAt { get; set; }
public string? Reason { get; set; }
}
#endregion
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for quiet hours calendar management.
/// </summary>
public static class QuietHoursEndpoints
{
/// <summary>
/// Maps quiet hours endpoints.
/// </summary>
public static IEndpointRouteBuilder MapQuietHoursEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/quiet-hours")
.WithTags("QuietHours")
.WithOpenApi();
group.MapGet("/calendars", ListCalendarsAsync)
.WithName("ListQuietHoursCalendars")
.WithSummary("List all quiet hours calendars")
.WithDescription("Returns all quiet hours calendars for the tenant.");
group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
.WithName("GetQuietHoursCalendar")
.WithSummary("Get a quiet hours calendar")
.WithDescription("Returns a specific quiet hours calendar by ID.");
group.MapPost("/calendars", CreateCalendarAsync)
.WithName("CreateQuietHoursCalendar")
.WithSummary("Create a quiet hours calendar")
.WithDescription("Creates a new quiet hours calendar with schedules.");
group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
.WithName("UpdateQuietHoursCalendar")
.WithSummary("Update a quiet hours calendar")
.WithDescription("Updates an existing quiet hours calendar.");
group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
.WithName("DeleteQuietHoursCalendar")
.WithSummary("Delete a quiet hours calendar")
.WithDescription("Deletes a quiet hours calendar.");
group.MapPost("/evaluate", EvaluateAsync)
.WithName("EvaluateQuietHours")
.WithSummary("Evaluate quiet hours")
.WithDescription("Checks if quiet hours are currently active for an event kind.");
return app;
}
private static async Task<IResult> ListCalendarsAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var calendars = await calendarService.ListCalendarsAsync(tenantId, cancellationToken);
return Results.Ok(calendars.Select(MapToApiResponse));
}
private static async Task<IResult> GetCalendarAsync(
string calendarId,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var calendar = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
if (calendar is null)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
}
return Results.Ok(MapToApiResponse(calendar));
}
private static async Task<IResult> CreateCalendarAsync(
[FromBody] QuietHoursCalendarApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Calendar name is required." });
}
if (request.Schedules is null || request.Schedules.Count == 0)
{
return Results.BadRequest(new { error = "At least one schedule is required." });
}
var calendarId = request.CalendarId ?? Guid.NewGuid().ToString("N")[..16];
var calendar = new QuietHoursCalendar
{
CalendarId = calendarId,
TenantId = tenantId,
Name = request.Name,
Description = request.Description,
Enabled = request.Enabled ?? true,
Priority = request.Priority ?? 100,
Schedules = request.Schedules.Select(MapToScheduleEntry).ToList(),
ExcludedEventKinds = request.ExcludedEventKinds,
IncludedEventKinds = request.IncludedEventKinds
};
var created = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
return Results.Created($"/api/v2/quiet-hours/calendars/{created.CalendarId}", MapToApiResponse(created));
}
private static async Task<IResult> UpdateCalendarAsync(
string calendarId,
[FromBody] QuietHoursCalendarApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
var existing = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
}
var calendar = new QuietHoursCalendar
{
CalendarId = calendarId,
TenantId = tenantId,
Name = request.Name ?? existing.Name,
Description = request.Description ?? existing.Description,
Enabled = request.Enabled ?? existing.Enabled,
Priority = request.Priority ?? existing.Priority,
Schedules = request.Schedules?.Select(MapToScheduleEntry).ToList() ?? existing.Schedules,
ExcludedEventKinds = request.ExcludedEventKinds ?? existing.ExcludedEventKinds,
IncludedEventKinds = request.IncludedEventKinds ?? existing.IncludedEventKinds,
CreatedAt = existing.CreatedAt,
CreatedBy = existing.CreatedBy
};
var updated = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
return Results.Ok(MapToApiResponse(updated));
}
private static async Task<IResult> DeleteCalendarAsync(
string calendarId,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var deleted = await calendarService.DeleteCalendarAsync(tenantId, calendarId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
}
return Results.NoContent();
}
private static async Task<IResult> EvaluateAsync(
[FromBody] QuietHoursEvaluateApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] IQuietHoursCalendarService calendarService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
}
var result = await calendarService.EvaluateAsync(
tenantId,
request.EventKind,
request.EvaluationTime,
cancellationToken);
return Results.Ok(new QuietHoursEvaluateApiResponse
{
IsActive = result.IsActive,
MatchedCalendarId = result.MatchedCalendarId,
MatchedCalendarName = result.MatchedCalendarName,
MatchedScheduleName = result.MatchedScheduleName,
EndsAt = result.EndsAt,
Reason = result.Reason
});
}
private static QuietHoursScheduleEntry MapToScheduleEntry(QuietHoursScheduleApiRequest request) => new()
{
Name = request.Name ?? "Unnamed Schedule",
StartTime = request.StartTime ?? "00:00",
EndTime = request.EndTime ?? "00:00",
DaysOfWeek = request.DaysOfWeek,
Timezone = request.Timezone,
Enabled = request.Enabled ?? true
};
private static QuietHoursCalendarApiResponse MapToApiResponse(QuietHoursCalendar calendar) => new()
{
CalendarId = calendar.CalendarId,
TenantId = calendar.TenantId,
Name = calendar.Name,
Description = calendar.Description,
Enabled = calendar.Enabled,
Priority = calendar.Priority,
Schedules = calendar.Schedules.Select(s => new QuietHoursScheduleApiResponse
{
Name = s.Name,
StartTime = s.StartTime,
EndTime = s.EndTime,
DaysOfWeek = s.DaysOfWeek?.ToList(),
Timezone = s.Timezone,
Enabled = s.Enabled
}).ToList(),
ExcludedEventKinds = calendar.ExcludedEventKinds?.ToList(),
IncludedEventKinds = calendar.IncludedEventKinds?.ToList(),
CreatedAt = calendar.CreatedAt,
CreatedBy = calendar.CreatedBy,
UpdatedAt = calendar.UpdatedAt,
UpdatedBy = calendar.UpdatedBy
};
}
#region API Request/Response Models
/// <summary>
/// Request to create or update a quiet hours calendar.
/// </summary>
public sealed class QuietHoursCalendarApiRequest
{
public string? CalendarId { get; set; }
public string? TenantId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public bool? Enabled { get; set; }
public int? Priority { get; set; }
public List<QuietHoursScheduleApiRequest>? Schedules { get; set; }
public List<string>? ExcludedEventKinds { get; set; }
public List<string>? IncludedEventKinds { get; set; }
}
/// <summary>
/// Schedule entry in a quiet hours calendar request.
/// </summary>
public sealed class QuietHoursScheduleApiRequest
{
public string? Name { get; set; }
public string? StartTime { get; set; }
public string? EndTime { get; set; }
public List<int>? DaysOfWeek { get; set; }
public string? Timezone { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Request to evaluate quiet hours.
/// </summary>
public sealed class QuietHoursEvaluateApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
public DateTimeOffset? EvaluationTime { get; set; }
}
/// <summary>
/// Response for a quiet hours calendar.
/// </summary>
public sealed class QuietHoursCalendarApiResponse
{
public required string CalendarId { get; set; }
public required string TenantId { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public required bool Enabled { get; set; }
public required int Priority { get; set; }
public required List<QuietHoursScheduleApiResponse> Schedules { get; set; }
public List<string>? ExcludedEventKinds { get; set; }
public List<string>? IncludedEventKinds { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
/// <summary>
/// Schedule entry in a quiet hours calendar response.
/// </summary>
public sealed class QuietHoursScheduleApiResponse
{
public required string Name { get; set; }
public required string StartTime { get; set; }
public required string EndTime { get; set; }
public List<int>? DaysOfWeek { get; set; }
public string? Timezone { get; set; }
public required bool Enabled { get; set; }
}
/// <summary>
/// Response for quiet hours evaluation.
/// </summary>
public sealed class QuietHoursEvaluateApiResponse
{
public required bool IsActive { get; set; }
public string? MatchedCalendarId { get; set; }
public string? MatchedCalendarName { get; set; }
public string? MatchedScheduleName { get; set; }
public DateTimeOffset? EndsAt { get; set; }
public string? Reason { get; set; }
}
#endregion

View File

@@ -1,406 +1,406 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.WebService.Contracts;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// Maps rule management endpoints.
/// </summary>
public static class RuleEndpoints
{
public static IEndpointRouteBuilder MapRuleEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/rules")
.WithTags("Rules");
group.MapGet("/", ListRulesAsync)
.WithName("ListRules")
.WithSummary("Lists all rules for a tenant");
group.MapGet("/{ruleId}", GetRuleAsync)
.WithName("GetRule")
.WithSummary("Gets a rule by ID");
group.MapPost("/", CreateRuleAsync)
.WithName("CreateRule")
.WithSummary("Creates a new rule");
group.MapPut("/{ruleId}", UpdateRuleAsync)
.WithName("UpdateRule")
.WithSummary("Updates an existing rule");
group.MapDelete("/{ruleId}", DeleteRuleAsync)
.WithName("DeleteRule")
.WithSummary("Deletes a rule");
return app;
}
private static async Task<IResult> ListRulesAsync(
HttpContext context,
INotifyRuleRepository rules,
bool? enabled = null,
string? keyPrefix = 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 allRules = await rules.ListAsync(tenantId, context.RequestAborted);
IEnumerable<NotifyRule> filtered = allRules;
if (enabled.HasValue)
{
filtered = filtered.Where(r => r.Enabled == enabled.Value);
}
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(r => r.Name.StartsWith(keyPrefix, 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> GetRuleAsync(
HttpContext context,
string ruleId,
INotifyRuleRepository rules)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var rule = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (rule is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
}
return Results.Ok(MapToResponse(rule));
}
private static async Task<IResult> CreateRuleAsync(
HttpContext context,
RuleCreateRequest request,
INotifyRuleRepository rules,
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);
// Check if rule already exists
var existing = await rules.GetAsync(tenantId, request.RuleId, context.RequestAborted);
if (existing is not null)
{
return Results.Conflict(Error("rule_exists", $"Rule '{request.RuleId}' already exists.", context));
}
var rule = MapFromRequest(request, tenantId, actor, timeProvider);
await rules.UpsertAsync(rule, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "rule.created", request.RuleId, "rule", request, timeProvider, context.RequestAborted);
return Results.Created($"/api/v2/rules/{rule.RuleId}", MapToResponse(rule));
}
private static async Task<IResult> UpdateRuleAsync(
HttpContext context,
string ruleId,
RuleUpdateRequest request,
INotifyRuleRepository rules,
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 rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
}
var updated = MergeUpdate(existing, request, actor, timeProvider);
await rules.UpsertAsync(updated, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "rule.updated", ruleId, "rule", request, timeProvider, context.RequestAborted);
return Results.Ok(MapToResponse(updated));
}
private static async Task<IResult> DeleteRuleAsync(
HttpContext context,
string ruleId,
INotifyRuleRepository rules,
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 rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
}
await rules.DeleteAsync(tenantId, ruleId, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "rule.deleted", ruleId, "rule", new { ruleId }, timeProvider, context.RequestAborted);
return Results.NoContent();
}
private static NotifyRule MapFromRequest(RuleCreateRequest request, string tenantId, string actor, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow();
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: ParseThrottle(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 MergeUpdate(NotifyRule existing, RuleUpdateRequest request, string actor, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow();
var match = request.Match is not null
? NotifyRuleMatch.Create(
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.AsEnumerable(),
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.AsEnumerable(),
repositories: request.Match.Repositories ?? existing.Match.Repositories.AsEnumerable(),
digests: request.Match.Digests ?? existing.Match.Digests.AsEnumerable(),
labels: request.Match.Labels ?? existing.Match.Labels.AsEnumerable(),
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.AsEnumerable(),
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.AsEnumerable(),
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: ParseThrottle(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(kvp => kvp.Key, kvp => kvp.Value),
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
createdBy: existing.CreatedBy,
createdAt: existing.CreatedAt,
updatedBy: actor,
updatedAt: now);
}
private static RuleResponse MapToResponse(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(kvp => kvp.Key, kvp => kvp.Value)
}).ToList(),
Labels = rule.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
Metadata = rule.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
CreatedBy = rule.CreatedBy,
CreatedAt = rule.CreatedAt,
UpdatedBy = rule.UpdatedBy,
UpdatedAt = rule.UpdatedAt
};
}
private static TimeSpan? ParseThrottle(string? throttle)
{
if (string.IsNullOrWhiteSpace(throttle))
{
return null;
}
// Try parsing as TimeSpan directly
if (TimeSpan.TryParse(throttle, out var ts))
{
return ts;
}
// Try parsing ISO 8601 duration (simplified: PT1H, PT30M, etc.)
if (throttle.StartsWith("PT", StringComparison.OrdinalIgnoreCase) && throttle.Length > 2)
{
var value = throttle[2..^1];
var unit = throttle[^1];
if (int.TryParse(value, out var num))
{
return char.ToUpperInvariant(unit) switch
{
'H' => TimeSpan.FromHours(num),
'M' => TimeSpan.FromMinutes(num),
'S' => TimeSpan.FromSeconds(num),
_ => null
};
}
}
return null;
}
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 = JsonSerializer.SerializeToNode(payload) as JsonObject
};
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
}
};
}
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.WebService.Contracts;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// Maps rule management endpoints.
/// </summary>
public static class RuleEndpoints
{
public static IEndpointRouteBuilder MapRuleEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/rules")
.WithTags("Rules");
group.MapGet("/", ListRulesAsync)
.WithName("ListRules")
.WithSummary("Lists all rules for a tenant");
group.MapGet("/{ruleId}", GetRuleAsync)
.WithName("GetRule")
.WithSummary("Gets a rule by ID");
group.MapPost("/", CreateRuleAsync)
.WithName("CreateRule")
.WithSummary("Creates a new rule");
group.MapPut("/{ruleId}", UpdateRuleAsync)
.WithName("UpdateRule")
.WithSummary("Updates an existing rule");
group.MapDelete("/{ruleId}", DeleteRuleAsync)
.WithName("DeleteRule")
.WithSummary("Deletes a rule");
return app;
}
private static async Task<IResult> ListRulesAsync(
HttpContext context,
INotifyRuleRepository rules,
bool? enabled = null,
string? keyPrefix = 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 allRules = await rules.ListAsync(tenantId, context.RequestAborted);
IEnumerable<NotifyRule> filtered = allRules;
if (enabled.HasValue)
{
filtered = filtered.Where(r => r.Enabled == enabled.Value);
}
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(r => r.Name.StartsWith(keyPrefix, 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> GetRuleAsync(
HttpContext context,
string ruleId,
INotifyRuleRepository rules)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var rule = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (rule is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
}
return Results.Ok(MapToResponse(rule));
}
private static async Task<IResult> CreateRuleAsync(
HttpContext context,
RuleCreateRequest request,
INotifyRuleRepository rules,
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);
// Check if rule already exists
var existing = await rules.GetAsync(tenantId, request.RuleId, context.RequestAborted);
if (existing is not null)
{
return Results.Conflict(Error("rule_exists", $"Rule '{request.RuleId}' already exists.", context));
}
var rule = MapFromRequest(request, tenantId, actor, timeProvider);
await rules.UpsertAsync(rule, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "rule.created", request.RuleId, "rule", request, timeProvider, context.RequestAborted);
return Results.Created($"/api/v2/rules/{rule.RuleId}", MapToResponse(rule));
}
private static async Task<IResult> UpdateRuleAsync(
HttpContext context,
string ruleId,
RuleUpdateRequest request,
INotifyRuleRepository rules,
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 rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
}
var updated = MergeUpdate(existing, request, actor, timeProvider);
await rules.UpsertAsync(updated, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "rule.updated", ruleId, "rule", request, timeProvider, context.RequestAborted);
return Results.Ok(MapToResponse(updated));
}
private static async Task<IResult> DeleteRuleAsync(
HttpContext context,
string ruleId,
INotifyRuleRepository rules,
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 rules.GetAsync(tenantId, ruleId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
}
await rules.DeleteAsync(tenantId, ruleId, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "rule.deleted", ruleId, "rule", new { ruleId }, timeProvider, context.RequestAborted);
return Results.NoContent();
}
private static NotifyRule MapFromRequest(RuleCreateRequest request, string tenantId, string actor, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow();
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: ParseThrottle(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 MergeUpdate(NotifyRule existing, RuleUpdateRequest request, string actor, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow();
var match = request.Match is not null
? NotifyRuleMatch.Create(
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.AsEnumerable(),
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.AsEnumerable(),
repositories: request.Match.Repositories ?? existing.Match.Repositories.AsEnumerable(),
digests: request.Match.Digests ?? existing.Match.Digests.AsEnumerable(),
labels: request.Match.Labels ?? existing.Match.Labels.AsEnumerable(),
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.AsEnumerable(),
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.AsEnumerable(),
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: ParseThrottle(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(kvp => kvp.Key, kvp => kvp.Value),
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
createdBy: existing.CreatedBy,
createdAt: existing.CreatedAt,
updatedBy: actor,
updatedAt: now);
}
private static RuleResponse MapToResponse(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(kvp => kvp.Key, kvp => kvp.Value)
}).ToList(),
Labels = rule.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
Metadata = rule.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
CreatedBy = rule.CreatedBy,
CreatedAt = rule.CreatedAt,
UpdatedBy = rule.UpdatedBy,
UpdatedAt = rule.UpdatedAt
};
}
private static TimeSpan? ParseThrottle(string? throttle)
{
if (string.IsNullOrWhiteSpace(throttle))
{
return null;
}
// Try parsing as TimeSpan directly
if (TimeSpan.TryParse(throttle, out var ts))
{
return ts;
}
// Try parsing ISO 8601 duration (simplified: PT1H, PT30M, etc.)
if (throttle.StartsWith("PT", StringComparison.OrdinalIgnoreCase) && throttle.Length > 2)
{
var value = throttle[2..^1];
var unit = throttle[^1];
if (int.TryParse(value, out var num))
{
return char.ToUpperInvariant(unit) switch
{
'H' => TimeSpan.FromHours(num),
'M' => TimeSpan.FromMinutes(num),
'S' => TimeSpan.FromSeconds(num),
_ => null
};
}
}
return null;
}
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 = JsonSerializer.SerializeToNode(payload) as JsonObject
};
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
}
};
}

View File

@@ -1,333 +1,333 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Security;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST endpoints for security services.
/// </summary>
public static class SecurityEndpoints
{
public static IEndpointRouteBuilder MapSecurityEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/security")
.WithTags("Security");
// Signing endpoints
group.MapPost("/tokens/sign", SignTokenAsync)
.WithName("SignToken")
.WithDescription("Signs a payload and returns a token.");
group.MapPost("/tokens/verify", VerifyTokenAsync)
.WithName("VerifyToken")
.WithDescription("Verifies a token and returns the payload if valid.");
group.MapGet("/tokens/{token}/info", GetTokenInfo)
.WithName("GetTokenInfo")
.WithDescription("Gets information about a token without verification.");
group.MapPost("/keys/rotate", RotateKeyAsync)
.WithName("RotateSigningKey")
.WithDescription("Rotates the signing key.");
// Webhook security endpoints
group.MapPost("/webhooks", RegisterWebhookConfigAsync)
.WithName("RegisterWebhookConfig")
.WithDescription("Registers webhook security configuration.");
group.MapGet("/webhooks/{tenantId}/{channelId}", GetWebhookConfigAsync)
.WithName("GetWebhookConfig")
.WithDescription("Gets webhook security configuration.");
group.MapPost("/webhooks/validate", ValidateWebhookAsync)
.WithName("ValidateWebhook")
.WithDescription("Validates a webhook request.");
group.MapPut("/webhooks/{tenantId}/{channelId}/allowlist", UpdateWebhookAllowlistAsync)
.WithName("UpdateWebhookAllowlist")
.WithDescription("Updates IP allowlist for a webhook.");
// HTML sanitization endpoints
group.MapPost("/html/sanitize", SanitizeHtmlAsync)
.WithName("SanitizeHtml")
.WithDescription("Sanitizes HTML content.");
group.MapPost("/html/validate", ValidateHtmlAsync)
.WithName("ValidateHtml")
.WithDescription("Validates HTML content.");
group.MapPost("/html/strip", StripHtmlTagsAsync)
.WithName("StripHtmlTags")
.WithDescription("Strips all HTML tags from content.");
// Tenant isolation endpoints
group.MapPost("/tenants/validate", ValidateTenantAccessAsync)
.WithName("ValidateTenantAccess")
.WithDescription("Validates tenant access to a resource.");
group.MapGet("/tenants/{tenantId}/violations", GetTenantViolationsAsync)
.WithName("GetTenantViolations")
.WithDescription("Gets tenant isolation violations.");
group.MapPost("/tenants/fuzz-test", RunTenantFuzzTestAsync)
.WithName("RunTenantFuzzTest")
.WithDescription("Runs tenant isolation fuzz tests.");
group.MapPost("/tenants/grants", GrantCrossTenantAccessAsync)
.WithName("GrantCrossTenantAccess")
.WithDescription("Grants cross-tenant access to a resource.");
group.MapDelete("/tenants/grants", RevokeCrossTenantAccessAsync)
.WithName("RevokeCrossTenantAccess")
.WithDescription("Revokes cross-tenant access.");
return endpoints;
}
// Signing endpoints
private static async Task<IResult> SignTokenAsync(
[FromBody] SignTokenRequest request,
[FromServices] ISigningService signingService,
CancellationToken cancellationToken)
{
var payload = new SigningPayload
{
TokenId = request.TokenId ?? Guid.NewGuid().ToString("N")[..16],
Purpose = request.Purpose,
TenantId = request.TenantId,
Subject = request.Subject,
Target = request.Target,
ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddHours(24),
Claims = request.Claims ?? new Dictionary<string, string>()
};
var token = await signingService.SignAsync(payload, cancellationToken);
return Results.Ok(new { token });
}
private static async Task<IResult> VerifyTokenAsync(
[FromBody] VerifyTokenRequest request,
[FromServices] ISigningService signingService,
CancellationToken cancellationToken)
{
var result = await signingService.VerifyAsync(request.Token, cancellationToken);
return Results.Ok(result);
}
private static IResult GetTokenInfo(
string token,
[FromServices] ISigningService signingService)
{
var info = signingService.GetTokenInfo(token);
return info is not null ? Results.Ok(info) : Results.NotFound();
}
private static async Task<IResult> RotateKeyAsync(
[FromServices] ISigningService signingService,
CancellationToken cancellationToken)
{
var success = await signingService.RotateKeyAsync(cancellationToken);
return success ? Results.Ok(new { message = "Key rotated successfully" }) : Results.Problem("Failed to rotate key");
}
// Webhook security endpoints
private static async Task<IResult> RegisterWebhookConfigAsync(
[FromBody] WebhookSecurityConfig config,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
await webhookService.RegisterWebhookAsync(config, cancellationToken);
return Results.Created($"/api/v2/security/webhooks/{config.TenantId}/{config.ChannelId}", config);
}
private static async Task<IResult> GetWebhookConfigAsync(
string tenantId,
string channelId,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
var config = await webhookService.GetConfigAsync(tenantId, channelId, cancellationToken);
return config is not null ? Results.Ok(config) : Results.NotFound();
}
private static async Task<IResult> ValidateWebhookAsync(
[FromBody] WebhookValidationRequest request,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
var result = await webhookService.ValidateAsync(request, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> UpdateWebhookAllowlistAsync(
string tenantId,
string channelId,
[FromBody] UpdateAllowlistRequest request,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
await webhookService.UpdateAllowlistAsync(tenantId, channelId, request.AllowedIps, request.Actor, cancellationToken);
return Results.NoContent();
}
// HTML sanitization endpoints
private static Task<IResult> SanitizeHtmlAsync(
[FromBody] SanitizeHtmlRequest request,
[FromServices] IHtmlSanitizer sanitizer)
{
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
var sanitized = sanitizer.Sanitize(request.Html, profile);
return Task.FromResult(Results.Ok(new { sanitized }));
}
private static Task<IResult> ValidateHtmlAsync(
[FromBody] ValidateHtmlRequest request,
[FromServices] IHtmlSanitizer sanitizer)
{
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
var result = sanitizer.Validate(request.Html, profile);
return Task.FromResult(Results.Ok(result));
}
private static Task<IResult> StripHtmlTagsAsync(
[FromBody] StripHtmlRequest request,
[FromServices] IHtmlSanitizer sanitizer)
{
var stripped = sanitizer.StripTags(request.Html);
return Task.FromResult(Results.Ok(new { text = stripped }));
}
// Tenant isolation endpoints
private static async Task<IResult> ValidateTenantAccessAsync(
[FromBody] ValidateTenantAccessRequest request,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
var result = await validator.ValidateResourceAccessAsync(
request.TenantId,
request.ResourceType,
request.ResourceId,
request.Operation,
cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetTenantViolationsAsync(
string tenantId,
[FromQuery] DateTimeOffset? since,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
var violations = await validator.GetViolationsAsync(tenantId, since, cancellationToken);
return Results.Ok(violations);
}
private static async Task<IResult> RunTenantFuzzTestAsync(
[FromBody] TenantFuzzTestConfig config,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
var result = await validator.RunFuzzTestAsync(config, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GrantCrossTenantAccessAsync(
[FromBody] CrossTenantGrantRequest request,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
await validator.GrantCrossTenantAccessAsync(
request.OwnerTenantId,
request.TargetTenantId,
request.ResourceType,
request.ResourceId,
request.AllowedOperations,
request.ExpiresAt,
request.GrantedBy,
cancellationToken);
return Results.Created();
}
private static async Task<IResult> RevokeCrossTenantAccessAsync(
[FromBody] RevokeCrossTenantRequest request,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
await validator.RevokeCrossTenantAccessAsync(
request.OwnerTenantId,
request.TargetTenantId,
request.ResourceType,
request.ResourceId,
request.RevokedBy,
cancellationToken);
return Results.NoContent();
}
}
// Request DTOs
public sealed record SignTokenRequest
{
public string? TokenId { get; init; }
public required string Purpose { get; init; }
public required string TenantId { get; init; }
public required string Subject { get; init; }
public string? Target { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public Dictionary<string, string>? Claims { get; init; }
}
public sealed record VerifyTokenRequest
{
public required string Token { get; init; }
}
public sealed record UpdateAllowlistRequest
{
public required IReadOnlyList<string> AllowedIps { get; init; }
public required string Actor { get; init; }
}
public sealed record SanitizeHtmlRequest
{
public required string Html { get; init; }
public string? Profile { get; init; }
}
public sealed record ValidateHtmlRequest
{
public required string Html { get; init; }
public string? Profile { get; init; }
}
public sealed record StripHtmlRequest
{
public required string Html { get; init; }
}
public sealed record ValidateTenantAccessRequest
{
public required string TenantId { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public TenantAccessOperation Operation { get; init; } = TenantAccessOperation.Read;
}
public sealed record CrossTenantGrantRequest
{
public required string OwnerTenantId { get; init; }
public required string TargetTenantId { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public required TenantAccessOperation AllowedOperations { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public required string GrantedBy { get; init; }
}
public sealed record RevokeCrossTenantRequest
{
public required string OwnerTenantId { get; init; }
public required string TargetTenantId { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public required string RevokedBy { get; init; }
}
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Security;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST endpoints for security services.
/// </summary>
public static class SecurityEndpoints
{
public static IEndpointRouteBuilder MapSecurityEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/security")
.WithTags("Security");
// Signing endpoints
group.MapPost("/tokens/sign", SignTokenAsync)
.WithName("SignToken")
.WithDescription("Signs a payload and returns a token.");
group.MapPost("/tokens/verify", VerifyTokenAsync)
.WithName("VerifyToken")
.WithDescription("Verifies a token and returns the payload if valid.");
group.MapGet("/tokens/{token}/info", GetTokenInfo)
.WithName("GetTokenInfo")
.WithDescription("Gets information about a token without verification.");
group.MapPost("/keys/rotate", RotateKeyAsync)
.WithName("RotateSigningKey")
.WithDescription("Rotates the signing key.");
// Webhook security endpoints
group.MapPost("/webhooks", RegisterWebhookConfigAsync)
.WithName("RegisterWebhookConfig")
.WithDescription("Registers webhook security configuration.");
group.MapGet("/webhooks/{tenantId}/{channelId}", GetWebhookConfigAsync)
.WithName("GetWebhookConfig")
.WithDescription("Gets webhook security configuration.");
group.MapPost("/webhooks/validate", ValidateWebhookAsync)
.WithName("ValidateWebhook")
.WithDescription("Validates a webhook request.");
group.MapPut("/webhooks/{tenantId}/{channelId}/allowlist", UpdateWebhookAllowlistAsync)
.WithName("UpdateWebhookAllowlist")
.WithDescription("Updates IP allowlist for a webhook.");
// HTML sanitization endpoints
group.MapPost("/html/sanitize", SanitizeHtmlAsync)
.WithName("SanitizeHtml")
.WithDescription("Sanitizes HTML content.");
group.MapPost("/html/validate", ValidateHtmlAsync)
.WithName("ValidateHtml")
.WithDescription("Validates HTML content.");
group.MapPost("/html/strip", StripHtmlTagsAsync)
.WithName("StripHtmlTags")
.WithDescription("Strips all HTML tags from content.");
// Tenant isolation endpoints
group.MapPost("/tenants/validate", ValidateTenantAccessAsync)
.WithName("ValidateTenantAccess")
.WithDescription("Validates tenant access to a resource.");
group.MapGet("/tenants/{tenantId}/violations", GetTenantViolationsAsync)
.WithName("GetTenantViolations")
.WithDescription("Gets tenant isolation violations.");
group.MapPost("/tenants/fuzz-test", RunTenantFuzzTestAsync)
.WithName("RunTenantFuzzTest")
.WithDescription("Runs tenant isolation fuzz tests.");
group.MapPost("/tenants/grants", GrantCrossTenantAccessAsync)
.WithName("GrantCrossTenantAccess")
.WithDescription("Grants cross-tenant access to a resource.");
group.MapDelete("/tenants/grants", RevokeCrossTenantAccessAsync)
.WithName("RevokeCrossTenantAccess")
.WithDescription("Revokes cross-tenant access.");
return endpoints;
}
// Signing endpoints
private static async Task<IResult> SignTokenAsync(
[FromBody] SignTokenRequest request,
[FromServices] ISigningService signingService,
CancellationToken cancellationToken)
{
var payload = new SigningPayload
{
TokenId = request.TokenId ?? Guid.NewGuid().ToString("N")[..16],
Purpose = request.Purpose,
TenantId = request.TenantId,
Subject = request.Subject,
Target = request.Target,
ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddHours(24),
Claims = request.Claims ?? new Dictionary<string, string>()
};
var token = await signingService.SignAsync(payload, cancellationToken);
return Results.Ok(new { token });
}
private static async Task<IResult> VerifyTokenAsync(
[FromBody] VerifyTokenRequest request,
[FromServices] ISigningService signingService,
CancellationToken cancellationToken)
{
var result = await signingService.VerifyAsync(request.Token, cancellationToken);
return Results.Ok(result);
}
private static IResult GetTokenInfo(
string token,
[FromServices] ISigningService signingService)
{
var info = signingService.GetTokenInfo(token);
return info is not null ? Results.Ok(info) : Results.NotFound();
}
private static async Task<IResult> RotateKeyAsync(
[FromServices] ISigningService signingService,
CancellationToken cancellationToken)
{
var success = await signingService.RotateKeyAsync(cancellationToken);
return success ? Results.Ok(new { message = "Key rotated successfully" }) : Results.Problem("Failed to rotate key");
}
// Webhook security endpoints
private static async Task<IResult> RegisterWebhookConfigAsync(
[FromBody] WebhookSecurityConfig config,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
await webhookService.RegisterWebhookAsync(config, cancellationToken);
return Results.Created($"/api/v2/security/webhooks/{config.TenantId}/{config.ChannelId}", config);
}
private static async Task<IResult> GetWebhookConfigAsync(
string tenantId,
string channelId,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
var config = await webhookService.GetConfigAsync(tenantId, channelId, cancellationToken);
return config is not null ? Results.Ok(config) : Results.NotFound();
}
private static async Task<IResult> ValidateWebhookAsync(
[FromBody] WebhookValidationRequest request,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
var result = await webhookService.ValidateAsync(request, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> UpdateWebhookAllowlistAsync(
string tenantId,
string channelId,
[FromBody] UpdateAllowlistRequest request,
[FromServices] IWebhookSecurityService webhookService,
CancellationToken cancellationToken)
{
await webhookService.UpdateAllowlistAsync(tenantId, channelId, request.AllowedIps, request.Actor, cancellationToken);
return Results.NoContent();
}
// HTML sanitization endpoints
private static Task<IResult> SanitizeHtmlAsync(
[FromBody] SanitizeHtmlRequest request,
[FromServices] IHtmlSanitizer sanitizer)
{
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
var sanitized = sanitizer.Sanitize(request.Html, profile);
return Task.FromResult(Results.Ok(new { sanitized }));
}
private static Task<IResult> ValidateHtmlAsync(
[FromBody] ValidateHtmlRequest request,
[FromServices] IHtmlSanitizer sanitizer)
{
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
var result = sanitizer.Validate(request.Html, profile);
return Task.FromResult(Results.Ok(result));
}
private static Task<IResult> StripHtmlTagsAsync(
[FromBody] StripHtmlRequest request,
[FromServices] IHtmlSanitizer sanitizer)
{
var stripped = sanitizer.StripTags(request.Html);
return Task.FromResult(Results.Ok(new { text = stripped }));
}
// Tenant isolation endpoints
private static async Task<IResult> ValidateTenantAccessAsync(
[FromBody] ValidateTenantAccessRequest request,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
var result = await validator.ValidateResourceAccessAsync(
request.TenantId,
request.ResourceType,
request.ResourceId,
request.Operation,
cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetTenantViolationsAsync(
string tenantId,
[FromQuery] DateTimeOffset? since,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
var violations = await validator.GetViolationsAsync(tenantId, since, cancellationToken);
return Results.Ok(violations);
}
private static async Task<IResult> RunTenantFuzzTestAsync(
[FromBody] TenantFuzzTestConfig config,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
var result = await validator.RunFuzzTestAsync(config, cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GrantCrossTenantAccessAsync(
[FromBody] CrossTenantGrantRequest request,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
await validator.GrantCrossTenantAccessAsync(
request.OwnerTenantId,
request.TargetTenantId,
request.ResourceType,
request.ResourceId,
request.AllowedOperations,
request.ExpiresAt,
request.GrantedBy,
cancellationToken);
return Results.Created();
}
private static async Task<IResult> RevokeCrossTenantAccessAsync(
[FromBody] RevokeCrossTenantRequest request,
[FromServices] ITenantIsolationValidator validator,
CancellationToken cancellationToken)
{
await validator.RevokeCrossTenantAccessAsync(
request.OwnerTenantId,
request.TargetTenantId,
request.ResourceType,
request.ResourceId,
request.RevokedBy,
cancellationToken);
return Results.NoContent();
}
}
// Request DTOs
public sealed record SignTokenRequest
{
public string? TokenId { get; init; }
public required string Purpose { get; init; }
public required string TenantId { get; init; }
public required string Subject { get; init; }
public string? Target { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public Dictionary<string, string>? Claims { get; init; }
}
public sealed record VerifyTokenRequest
{
public required string Token { get; init; }
}
public sealed record UpdateAllowlistRequest
{
public required IReadOnlyList<string> AllowedIps { get; init; }
public required string Actor { get; init; }
}
public sealed record SanitizeHtmlRequest
{
public required string Html { get; init; }
public string? Profile { get; init; }
}
public sealed record ValidateHtmlRequest
{
public required string Html { get; init; }
public string? Profile { get; init; }
}
public sealed record StripHtmlRequest
{
public required string Html { get; init; }
}
public sealed record ValidateTenantAccessRequest
{
public required string TenantId { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public TenantAccessOperation Operation { get; init; } = TenantAccessOperation.Read;
}
public sealed record CrossTenantGrantRequest
{
public required string OwnerTenantId { get; init; }
public required string TargetTenantId { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public required TenantAccessOperation AllowedOperations { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public required string GrantedBy { get; init; }
}
public sealed record RevokeCrossTenantRequest
{
public required string OwnerTenantId { get; init; }
public required string TargetTenantId { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public required string RevokedBy { get; init; }
}

View File

@@ -1,382 +1,382 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Simulation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for rule simulation.
/// </summary>
public static class SimulationEndpoints
{
/// <summary>
/// Maps simulation endpoints.
/// </summary>
public static IEndpointRouteBuilder MapSimulationEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/simulate")
.WithTags("Simulation")
.WithOpenApi();
group.MapPost("/", SimulateAsync)
.WithName("SimulateRules")
.WithSummary("Simulate rule evaluation against events")
.WithDescription("Dry-runs rules against provided or historical events without side effects. Returns matched actions with detailed explanations.");
group.MapPost("/validate", ValidateRuleAsync)
.WithName("ValidateRule")
.WithSummary("Validate a rule definition")
.WithDescription("Validates a rule definition and returns any errors or warnings.");
return app;
}
private static async Task<IResult> SimulateAsync(
[FromBody] SimulationApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] ISimulationEngine simulationEngine,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
// Convert API events to NotifyEvent
var events = request.Events?.Select(MapToNotifyEvent).ToList();
// Convert API rules to NotifyRule
var rules = request.Rules?.Select(r => MapToNotifyRule(r, tenantId)).ToList();
var simulationRequest = new SimulationRequest
{
TenantId = tenantId,
Events = events,
Rules = rules,
EnabledRulesOnly = request.EnabledRulesOnly ?? true,
HistoricalLookback = request.HistoricalLookbackMinutes.HasValue
? TimeSpan.FromMinutes(request.HistoricalLookbackMinutes.Value)
: null,
MaxEvents = request.MaxEvents ?? 100,
EventKindFilter = request.EventKindFilter,
IncludeNonMatches = request.IncludeNonMatches ?? false,
EvaluationTimestamp = request.EvaluationTimestamp
};
var result = await simulationEngine.SimulateAsync(simulationRequest, cancellationToken);
return Results.Ok(MapToApiResponse(result));
}
private static async Task<IResult> ValidateRuleAsync(
[FromBody] RuleApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] ISimulationEngine simulationEngine,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
var rule = MapToNotifyRule(request, tenantId);
var result = await simulationEngine.ValidateRuleAsync(rule, cancellationToken);
return Results.Ok(result);
}
private static NotifyEvent MapToNotifyEvent(EventApiRequest request)
{
return NotifyEvent.Create(
eventId: request.EventId ?? Guid.NewGuid(),
kind: request.Kind ?? "unknown",
tenant: request.TenantId ?? "default",
ts: request.Timestamp ?? DateTimeOffset.UtcNow,
payload: request.Payload is not null
? JsonNode.Parse(JsonSerializer.Serialize(request.Payload))
: null,
scope: request.Scope is not null
? NotifyEventScope.Create(
@namespace: request.Scope.Namespace,
repo: request.Scope.Repo,
digest: request.Scope.Digest,
component: request.Scope.Component,
image: request.Scope.Image,
labels: request.Scope.Labels)
: null,
attributes: request.Attributes);
}
private static NotifyRule MapToNotifyRule(RuleApiRequest request, string tenantId)
{
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 ?? Guid.NewGuid().ToString("N")[..8],
channel: a.Channel ?? "default",
template: a.Template,
throttle: a.ThrottleSeconds.HasValue
? TimeSpan.FromSeconds(a.ThrottleSeconds.Value)
: null,
enabled: a.Enabled ?? true)).ToList() ?? [];
return NotifyRule.Create(
ruleId: request.RuleId ?? Guid.NewGuid().ToString("N")[..16],
tenantId: request.TenantId ?? tenantId,
name: request.Name ?? "Unnamed Rule",
match: match,
actions: actions,
enabled: request.Enabled ?? true,
description: request.Description);
}
private static SimulationApiResponse MapToApiResponse(SimulationResult result)
{
return new SimulationApiResponse
{
SimulationId = result.SimulationId,
ExecutedAt = result.ExecutedAt,
TotalEvents = result.TotalEvents,
TotalRules = result.TotalRules,
MatchedEvents = result.MatchedEvents,
TotalActionsTriggered = result.TotalActionsTriggered,
DurationMs = result.Duration.TotalMilliseconds,
EventResults = result.EventResults.Select(e => new EventResultApiResponse
{
EventId = e.EventId,
EventKind = e.EventKind,
EventTimestamp = e.EventTimestamp,
Matched = e.Matched,
MatchedRules = e.MatchedRules.Select(r => new RuleMatchApiResponse
{
RuleId = r.RuleId,
RuleName = r.RuleName,
MatchedAt = r.MatchedAt,
Actions = r.Actions.Select(a => new ActionMatchApiResponse
{
ActionId = a.ActionId,
Channel = a.Channel,
Template = a.Template,
Enabled = a.Enabled,
ThrottleSeconds = a.Throttle?.TotalSeconds,
Explanation = a.Explanation
}).ToList()
}).ToList(),
NonMatchedRules = e.NonMatchedRules?.Select(r => new RuleNonMatchApiResponse
{
RuleId = r.RuleId,
RuleName = r.RuleName,
Reason = r.Reason,
Explanation = r.Explanation
}).ToList()
}).ToList(),
RuleSummaries = result.RuleSummaries.Select(s => new RuleSummaryApiResponse
{
RuleId = s.RuleId,
RuleName = s.RuleName,
Enabled = s.Enabled,
MatchCount = s.MatchCount,
ActionCount = s.ActionCount,
MatchPercentage = s.MatchPercentage,
TopNonMatchReasons = s.TopNonMatchReasons.Select(r => new NonMatchReasonApiResponse
{
Reason = r.Reason,
Explanation = r.Explanation,
Count = r.Count
}).ToList()
}).ToList()
};
}
}
#region API Request/Response Models
/// <summary>
/// Simulation API request.
/// </summary>
public sealed class SimulationApiRequest
{
public string? TenantId { get; set; }
public List<EventApiRequest>? Events { get; set; }
public List<RuleApiRequest>? Rules { get; set; }
public bool? EnabledRulesOnly { get; set; }
public int? HistoricalLookbackMinutes { get; set; }
public int? MaxEvents { get; set; }
public List<string>? EventKindFilter { get; set; }
public bool? IncludeNonMatches { get; set; }
public DateTimeOffset? EvaluationTimestamp { get; set; }
}
/// <summary>
/// Event for simulation.
/// </summary>
public sealed class EventApiRequest
{
public Guid? EventId { get; set; }
public string? Kind { get; set; }
public string? TenantId { get; set; }
public DateTimeOffset? Timestamp { get; set; }
public Dictionary<string, object>? Payload { get; set; }
public EventScopeApiRequest? Scope { get; set; }
public Dictionary<string, string>? Attributes { get; set; }
}
/// <summary>
/// Event scope for simulation.
/// </summary>
public sealed class EventScopeApiRequest
{
public string? Namespace { get; set; }
public string? Repo { get; set; }
public string? Digest { get; set; }
public string? Component { get; set; }
public string? Image { get; set; }
public Dictionary<string, string>? Labels { get; set; }
}
/// <summary>
/// Rule for simulation.
/// </summary>
public sealed class RuleApiRequest
{
public string? RuleId { get; set; }
public string? TenantId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public bool? Enabled { get; set; }
public RuleMatchApiRequest? Match { get; set; }
public List<RuleActionApiRequest>? Actions { get; set; }
}
/// <summary>
/// Rule match criteria for simulation.
/// </summary>
public sealed class RuleMatchApiRequest
{
public List<string>? EventKinds { get; set; }
public List<string>? Namespaces { get; set; }
public List<string>? Repositories { get; set; }
public List<string>? Digests { get; set; }
public List<string>? Labels { get; set; }
public List<string>? ComponentPurls { get; set; }
public string? MinSeverity { get; set; }
public List<string>? Verdicts { get; set; }
public bool? KevOnly { get; set; }
}
/// <summary>
/// Rule action for simulation.
/// </summary>
public sealed class RuleActionApiRequest
{
public string? ActionId { get; set; }
public string? Channel { get; set; }
public string? Template { get; set; }
public int? ThrottleSeconds { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Simulation API response.
/// </summary>
public sealed class SimulationApiResponse
{
public required string SimulationId { get; set; }
public required DateTimeOffset ExecutedAt { get; set; }
public required int TotalEvents { get; set; }
public required int TotalRules { get; set; }
public required int MatchedEvents { get; set; }
public required int TotalActionsTriggered { get; set; }
public required double DurationMs { get; set; }
public required List<EventResultApiResponse> EventResults { get; set; }
public required List<RuleSummaryApiResponse> RuleSummaries { get; set; }
}
/// <summary>
/// Event result in simulation response.
/// </summary>
public sealed class EventResultApiResponse
{
public required Guid EventId { get; set; }
public required string EventKind { get; set; }
public required DateTimeOffset EventTimestamp { get; set; }
public required bool Matched { get; set; }
public required List<RuleMatchApiResponse> MatchedRules { get; set; }
public List<RuleNonMatchApiResponse>? NonMatchedRules { get; set; }
}
/// <summary>
/// Rule match in simulation response.
/// </summary>
public sealed class RuleMatchApiResponse
{
public required string RuleId { get; set; }
public required string RuleName { get; set; }
public required DateTimeOffset MatchedAt { get; set; }
public required List<ActionMatchApiResponse> Actions { get; set; }
}
/// <summary>
/// Action match in simulation response.
/// </summary>
public sealed class ActionMatchApiResponse
{
public required string ActionId { get; set; }
public required string Channel { get; set; }
public string? Template { get; set; }
public required bool Enabled { get; set; }
public double? ThrottleSeconds { get; set; }
public required string Explanation { get; set; }
}
/// <summary>
/// Rule non-match in simulation response.
/// </summary>
public sealed class RuleNonMatchApiResponse
{
public required string RuleId { get; set; }
public required string RuleName { get; set; }
public required string Reason { get; set; }
public required string Explanation { get; set; }
}
/// <summary>
/// Rule summary in simulation response.
/// </summary>
public sealed class RuleSummaryApiResponse
{
public required string RuleId { get; set; }
public required string RuleName { get; set; }
public required bool Enabled { get; set; }
public required int MatchCount { get; set; }
public required int ActionCount { get; set; }
public required double MatchPercentage { get; set; }
public required List<NonMatchReasonApiResponse> TopNonMatchReasons { get; set; }
}
/// <summary>
/// Non-match reason summary in simulation response.
/// </summary>
public sealed class NonMatchReasonApiResponse
{
public required string Reason { get; set; }
public required string Explanation { get; set; }
public required int Count { get; set; }
}
#endregion
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Simulation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for rule simulation.
/// </summary>
public static class SimulationEndpoints
{
/// <summary>
/// Maps simulation endpoints.
/// </summary>
public static IEndpointRouteBuilder MapSimulationEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/simulate")
.WithTags("Simulation")
.WithOpenApi();
group.MapPost("/", SimulateAsync)
.WithName("SimulateRules")
.WithSummary("Simulate rule evaluation against events")
.WithDescription("Dry-runs rules against provided or historical events without side effects. Returns matched actions with detailed explanations.");
group.MapPost("/validate", ValidateRuleAsync)
.WithName("ValidateRule")
.WithSummary("Validate a rule definition")
.WithDescription("Validates a rule definition and returns any errors or warnings.");
return app;
}
private static async Task<IResult> SimulateAsync(
[FromBody] SimulationApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] ISimulationEngine simulationEngine,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
// Convert API events to NotifyEvent
var events = request.Events?.Select(MapToNotifyEvent).ToList();
// Convert API rules to NotifyRule
var rules = request.Rules?.Select(r => MapToNotifyRule(r, tenantId)).ToList();
var simulationRequest = new SimulationRequest
{
TenantId = tenantId,
Events = events,
Rules = rules,
EnabledRulesOnly = request.EnabledRulesOnly ?? true,
HistoricalLookback = request.HistoricalLookbackMinutes.HasValue
? TimeSpan.FromMinutes(request.HistoricalLookbackMinutes.Value)
: null,
MaxEvents = request.MaxEvents ?? 100,
EventKindFilter = request.EventKindFilter,
IncludeNonMatches = request.IncludeNonMatches ?? false,
EvaluationTimestamp = request.EvaluationTimestamp
};
var result = await simulationEngine.SimulateAsync(simulationRequest, cancellationToken);
return Results.Ok(MapToApiResponse(result));
}
private static async Task<IResult> ValidateRuleAsync(
[FromBody] RuleApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] ISimulationEngine simulationEngine,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
var rule = MapToNotifyRule(request, tenantId);
var result = await simulationEngine.ValidateRuleAsync(rule, cancellationToken);
return Results.Ok(result);
}
private static NotifyEvent MapToNotifyEvent(EventApiRequest request)
{
return NotifyEvent.Create(
eventId: request.EventId ?? Guid.NewGuid(),
kind: request.Kind ?? "unknown",
tenant: request.TenantId ?? "default",
ts: request.Timestamp ?? DateTimeOffset.UtcNow,
payload: request.Payload is not null
? JsonNode.Parse(JsonSerializer.Serialize(request.Payload))
: null,
scope: request.Scope is not null
? NotifyEventScope.Create(
@namespace: request.Scope.Namespace,
repo: request.Scope.Repo,
digest: request.Scope.Digest,
component: request.Scope.Component,
image: request.Scope.Image,
labels: request.Scope.Labels)
: null,
attributes: request.Attributes);
}
private static NotifyRule MapToNotifyRule(RuleApiRequest request, string tenantId)
{
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 ?? Guid.NewGuid().ToString("N")[..8],
channel: a.Channel ?? "default",
template: a.Template,
throttle: a.ThrottleSeconds.HasValue
? TimeSpan.FromSeconds(a.ThrottleSeconds.Value)
: null,
enabled: a.Enabled ?? true)).ToList() ?? [];
return NotifyRule.Create(
ruleId: request.RuleId ?? Guid.NewGuid().ToString("N")[..16],
tenantId: request.TenantId ?? tenantId,
name: request.Name ?? "Unnamed Rule",
match: match,
actions: actions,
enabled: request.Enabled ?? true,
description: request.Description);
}
private static SimulationApiResponse MapToApiResponse(SimulationResult result)
{
return new SimulationApiResponse
{
SimulationId = result.SimulationId,
ExecutedAt = result.ExecutedAt,
TotalEvents = result.TotalEvents,
TotalRules = result.TotalRules,
MatchedEvents = result.MatchedEvents,
TotalActionsTriggered = result.TotalActionsTriggered,
DurationMs = result.Duration.TotalMilliseconds,
EventResults = result.EventResults.Select(e => new EventResultApiResponse
{
EventId = e.EventId,
EventKind = e.EventKind,
EventTimestamp = e.EventTimestamp,
Matched = e.Matched,
MatchedRules = e.MatchedRules.Select(r => new RuleMatchApiResponse
{
RuleId = r.RuleId,
RuleName = r.RuleName,
MatchedAt = r.MatchedAt,
Actions = r.Actions.Select(a => new ActionMatchApiResponse
{
ActionId = a.ActionId,
Channel = a.Channel,
Template = a.Template,
Enabled = a.Enabled,
ThrottleSeconds = a.Throttle?.TotalSeconds,
Explanation = a.Explanation
}).ToList()
}).ToList(),
NonMatchedRules = e.NonMatchedRules?.Select(r => new RuleNonMatchApiResponse
{
RuleId = r.RuleId,
RuleName = r.RuleName,
Reason = r.Reason,
Explanation = r.Explanation
}).ToList()
}).ToList(),
RuleSummaries = result.RuleSummaries.Select(s => new RuleSummaryApiResponse
{
RuleId = s.RuleId,
RuleName = s.RuleName,
Enabled = s.Enabled,
MatchCount = s.MatchCount,
ActionCount = s.ActionCount,
MatchPercentage = s.MatchPercentage,
TopNonMatchReasons = s.TopNonMatchReasons.Select(r => new NonMatchReasonApiResponse
{
Reason = r.Reason,
Explanation = r.Explanation,
Count = r.Count
}).ToList()
}).ToList()
};
}
}
#region API Request/Response Models
/// <summary>
/// Simulation API request.
/// </summary>
public sealed class SimulationApiRequest
{
public string? TenantId { get; set; }
public List<EventApiRequest>? Events { get; set; }
public List<RuleApiRequest>? Rules { get; set; }
public bool? EnabledRulesOnly { get; set; }
public int? HistoricalLookbackMinutes { get; set; }
public int? MaxEvents { get; set; }
public List<string>? EventKindFilter { get; set; }
public bool? IncludeNonMatches { get; set; }
public DateTimeOffset? EvaluationTimestamp { get; set; }
}
/// <summary>
/// Event for simulation.
/// </summary>
public sealed class EventApiRequest
{
public Guid? EventId { get; set; }
public string? Kind { get; set; }
public string? TenantId { get; set; }
public DateTimeOffset? Timestamp { get; set; }
public Dictionary<string, object>? Payload { get; set; }
public EventScopeApiRequest? Scope { get; set; }
public Dictionary<string, string>? Attributes { get; set; }
}
/// <summary>
/// Event scope for simulation.
/// </summary>
public sealed class EventScopeApiRequest
{
public string? Namespace { get; set; }
public string? Repo { get; set; }
public string? Digest { get; set; }
public string? Component { get; set; }
public string? Image { get; set; }
public Dictionary<string, string>? Labels { get; set; }
}
/// <summary>
/// Rule for simulation.
/// </summary>
public sealed class RuleApiRequest
{
public string? RuleId { get; set; }
public string? TenantId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public bool? Enabled { get; set; }
public RuleMatchApiRequest? Match { get; set; }
public List<RuleActionApiRequest>? Actions { get; set; }
}
/// <summary>
/// Rule match criteria for simulation.
/// </summary>
public sealed class RuleMatchApiRequest
{
public List<string>? EventKinds { get; set; }
public List<string>? Namespaces { get; set; }
public List<string>? Repositories { get; set; }
public List<string>? Digests { get; set; }
public List<string>? Labels { get; set; }
public List<string>? ComponentPurls { get; set; }
public string? MinSeverity { get; set; }
public List<string>? Verdicts { get; set; }
public bool? KevOnly { get; set; }
}
/// <summary>
/// Rule action for simulation.
/// </summary>
public sealed class RuleActionApiRequest
{
public string? ActionId { get; set; }
public string? Channel { get; set; }
public string? Template { get; set; }
public int? ThrottleSeconds { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Simulation API response.
/// </summary>
public sealed class SimulationApiResponse
{
public required string SimulationId { get; set; }
public required DateTimeOffset ExecutedAt { get; set; }
public required int TotalEvents { get; set; }
public required int TotalRules { get; set; }
public required int MatchedEvents { get; set; }
public required int TotalActionsTriggered { get; set; }
public required double DurationMs { get; set; }
public required List<EventResultApiResponse> EventResults { get; set; }
public required List<RuleSummaryApiResponse> RuleSummaries { get; set; }
}
/// <summary>
/// Event result in simulation response.
/// </summary>
public sealed class EventResultApiResponse
{
public required Guid EventId { get; set; }
public required string EventKind { get; set; }
public required DateTimeOffset EventTimestamp { get; set; }
public required bool Matched { get; set; }
public required List<RuleMatchApiResponse> MatchedRules { get; set; }
public List<RuleNonMatchApiResponse>? NonMatchedRules { get; set; }
}
/// <summary>
/// Rule match in simulation response.
/// </summary>
public sealed class RuleMatchApiResponse
{
public required string RuleId { get; set; }
public required string RuleName { get; set; }
public required DateTimeOffset MatchedAt { get; set; }
public required List<ActionMatchApiResponse> Actions { get; set; }
}
/// <summary>
/// Action match in simulation response.
/// </summary>
public sealed class ActionMatchApiResponse
{
public required string ActionId { get; set; }
public required string Channel { get; set; }
public string? Template { get; set; }
public required bool Enabled { get; set; }
public double? ThrottleSeconds { get; set; }
public required string Explanation { get; set; }
}
/// <summary>
/// Rule non-match in simulation response.
/// </summary>
public sealed class RuleNonMatchApiResponse
{
public required string RuleId { get; set; }
public required string RuleName { get; set; }
public required string Reason { get; set; }
public required string Explanation { get; set; }
}
/// <summary>
/// Rule summary in simulation response.
/// </summary>
public sealed class RuleSummaryApiResponse
{
public required string RuleId { get; set; }
public required string RuleName { get; set; }
public required bool Enabled { get; set; }
public required int MatchCount { get; set; }
public required int ActionCount { get; set; }
public required double MatchPercentage { get; set; }
public required List<NonMatchReasonApiResponse> TopNonMatchReasons { get; set; }
}
/// <summary>
/// Non-match reason summary in simulation response.
/// </summary>
public sealed class NonMatchReasonApiResponse
{
public required string Reason { get; set; }
public required string Explanation { get; set; }
public required int Count { get; set; }
}
#endregion

View File

@@ -1,121 +1,121 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST API endpoints for storm breaker operations.
/// </summary>
public static class StormBreakerEndpoints
{
/// <summary>
/// Maps storm breaker API endpoints.
/// </summary>
public static RouteGroupBuilder MapStormBreakerEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/storm-breaker")
.WithTags("Storm Breaker")
.WithOpenApi();
// List active storms for tenant
group.MapGet("/storms", async (
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var storms = await stormBreaker.GetActiveStormsAsync(tenantId, cancellationToken);
return Results.Ok(new
{
tenantId,
activeStorms = storms.Select(s => new
{
s.TenantId,
s.StormKey,
s.StartedAt,
eventCount = s.EventIds.Count,
s.SuppressedCount,
s.LastActivityAt,
s.IsActive
}).ToList(),
count = storms.Count
});
})
.WithName("ListActiveStorms")
.WithSummary("Lists all active notification storms for a tenant");
// Get specific storm state
group.MapGet("/storms/{stormKey}", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var state = await stormBreaker.GetStateAsync(tenantId, stormKey, cancellationToken);
if (state is null)
{
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
}
return Results.Ok(new
{
state.TenantId,
state.StormKey,
state.StartedAt,
eventCount = state.EventIds.Count,
state.SuppressedCount,
state.LastActivityAt,
state.LastSummaryAt,
state.IsActive,
sampleEventIds = state.EventIds.Take(10).ToList()
});
})
.WithName("GetStormState")
.WithSummary("Gets the current state of a specific storm");
// Generate storm summary
group.MapPost("/storms/{stormKey}/summary", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, cancellationToken);
if (summary is null)
{
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
}
return Results.Ok(summary);
})
.WithName("GenerateStormSummary")
.WithSummary("Generates a summary for an active storm");
// Clear storm state
group.MapDelete("/storms/{stormKey}", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
await stormBreaker.ClearAsync(tenantId, stormKey, cancellationToken);
return Results.Ok(new { message = $"Storm '{stormKey}' cleared successfully" });
})
.WithName("ClearStorm")
.WithSummary("Clears a storm state manually");
return group;
}
}
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// REST API endpoints for storm breaker operations.
/// </summary>
public static class StormBreakerEndpoints
{
/// <summary>
/// Maps storm breaker API endpoints.
/// </summary>
public static RouteGroupBuilder MapStormBreakerEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v2/storm-breaker")
.WithTags("Storm Breaker")
.WithOpenApi();
// List active storms for tenant
group.MapGet("/storms", async (
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var storms = await stormBreaker.GetActiveStormsAsync(tenantId, cancellationToken);
return Results.Ok(new
{
tenantId,
activeStorms = storms.Select(s => new
{
s.TenantId,
s.StormKey,
s.StartedAt,
eventCount = s.EventIds.Count,
s.SuppressedCount,
s.LastActivityAt,
s.IsActive
}).ToList(),
count = storms.Count
});
})
.WithName("ListActiveStorms")
.WithSummary("Lists all active notification storms for a tenant");
// Get specific storm state
group.MapGet("/storms/{stormKey}", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var state = await stormBreaker.GetStateAsync(tenantId, stormKey, cancellationToken);
if (state is null)
{
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
}
return Results.Ok(new
{
state.TenantId,
state.StormKey,
state.StartedAt,
eventCount = state.EventIds.Count,
state.SuppressedCount,
state.LastActivityAt,
state.LastSummaryAt,
state.IsActive,
sampleEventIds = state.EventIds.Take(10).ToList()
});
})
.WithName("GetStormState")
.WithSummary("Gets the current state of a specific storm");
// Generate storm summary
group.MapPost("/storms/{stormKey}/summary", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, cancellationToken);
if (summary is null)
{
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
}
return Results.Ok(summary);
})
.WithName("GenerateStormSummary")
.WithSummary("Generates a summary for an active storm");
// Clear storm state
group.MapDelete("/storms/{stormKey}", async (
string stormKey,
HttpContext context,
IStormBreaker stormBreaker,
CancellationToken cancellationToken) =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
await stormBreaker.ClearAsync(tenantId, stormKey, cancellationToken);
return Results.Ok(new { message = $"Storm '{stormKey}' cleared successfully" });
})
.WithName("ClearStorm")
.WithSummary("Clears a storm state manually");
return group;
}
}

View File

@@ -1,418 +1,418 @@
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.Notifier.Worker.Storage;
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 = JsonSerializer.SerializeToNode(payload) as JsonObject
};
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
}
};
}
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.Notifier.Worker.Storage;
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 = JsonSerializer.SerializeToNode(payload) as JsonObject
};
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
}
};
}

View File

@@ -1,228 +1,228 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for throttle configuration management.
/// </summary>
public static class ThrottleEndpoints
{
/// <summary>
/// Maps throttle configuration endpoints.
/// </summary>
public static IEndpointRouteBuilder MapThrottleEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/throttles")
.WithTags("Throttles")
.WithOpenApi();
group.MapGet("/config", GetConfigurationAsync)
.WithName("GetThrottleConfiguration")
.WithSummary("Get throttle configuration")
.WithDescription("Returns the throttle configuration for the tenant.");
group.MapPut("/config", UpdateConfigurationAsync)
.WithName("UpdateThrottleConfiguration")
.WithSummary("Update throttle configuration")
.WithDescription("Creates or updates the throttle configuration for the tenant.");
group.MapDelete("/config", DeleteConfigurationAsync)
.WithName("DeleteThrottleConfiguration")
.WithSummary("Delete throttle configuration")
.WithDescription("Deletes the throttle configuration for the tenant, reverting to defaults.");
group.MapPost("/evaluate", EvaluateAsync)
.WithName("EvaluateThrottle")
.WithSummary("Evaluate throttle duration")
.WithDescription("Returns the effective throttle duration for an event kind.");
return app;
}
private static async Task<IResult> GetConfigurationAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var config = await throttleService.GetConfigurationAsync(tenantId, cancellationToken);
if (config is null)
{
return Results.Ok(new ThrottleConfigurationApiResponse
{
TenantId = tenantId,
DefaultDurationSeconds = 900, // 15 minutes default
Enabled = true,
EventKindOverrides = new Dictionary<string, int>(),
IsDefault = true
});
}
return Results.Ok(MapToApiResponse(config));
}
private static async Task<IResult> UpdateConfigurationAsync(
[FromBody] ThrottleConfigurationApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (request.DefaultDurationSeconds is <= 0)
{
return Results.BadRequest(new { error = "Default duration must be a positive value in seconds." });
}
var config = new ThrottleConfiguration
{
TenantId = tenantId,
DefaultDuration = TimeSpan.FromSeconds(request.DefaultDurationSeconds ?? 900),
EventKindOverrides = request.EventKindOverrides?
.ToDictionary(kvp => kvp.Key, kvp => TimeSpan.FromSeconds(kvp.Value)),
MaxEventsPerWindow = request.MaxEventsPerWindow,
BurstWindowDuration = request.BurstWindowDurationSeconds.HasValue
? TimeSpan.FromSeconds(request.BurstWindowDurationSeconds.Value)
: null,
Enabled = request.Enabled ?? true
};
var updated = await throttleService.UpsertConfigurationAsync(config, actor, cancellationToken);
return Results.Ok(MapToApiResponse(updated));
}
private static async Task<IResult> DeleteConfigurationAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var deleted = await throttleService.DeleteConfigurationAsync(tenantId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = "No throttle configuration exists for this tenant." });
}
return Results.NoContent();
}
private static async Task<IResult> EvaluateAsync(
[FromBody] ThrottleEvaluateApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
}
var duration = await throttleService.GetEffectiveThrottleDurationAsync(
tenantId,
request.EventKind,
cancellationToken);
return Results.Ok(new ThrottleEvaluateApiResponse
{
EventKind = request.EventKind,
EffectiveDurationSeconds = (int)duration.TotalSeconds
});
}
private static ThrottleConfigurationApiResponse MapToApiResponse(ThrottleConfiguration config) => new()
{
TenantId = config.TenantId,
DefaultDurationSeconds = (int)config.DefaultDuration.TotalSeconds,
EventKindOverrides = config.EventKindOverrides?
.ToDictionary(kvp => kvp.Key, kvp => (int)kvp.Value.TotalSeconds)
?? new Dictionary<string, int>(),
MaxEventsPerWindow = config.MaxEventsPerWindow,
BurstWindowDurationSeconds = config.BurstWindowDuration.HasValue
? (int)config.BurstWindowDuration.Value.TotalSeconds
: null,
Enabled = config.Enabled,
CreatedAt = config.CreatedAt,
CreatedBy = config.CreatedBy,
UpdatedAt = config.UpdatedAt,
UpdatedBy = config.UpdatedBy,
IsDefault = false
};
}
#region API Request/Response Models
/// <summary>
/// Request to create or update throttle configuration.
/// </summary>
public sealed class ThrottleConfigurationApiRequest
{
public string? TenantId { get; set; }
public int? DefaultDurationSeconds { get; set; }
public Dictionary<string, int>? EventKindOverrides { get; set; }
public int? MaxEventsPerWindow { get; set; }
public int? BurstWindowDurationSeconds { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Request to evaluate throttle duration.
/// </summary>
public sealed class ThrottleEvaluateApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
}
/// <summary>
/// Response for throttle configuration.
/// </summary>
public sealed class ThrottleConfigurationApiResponse
{
public required string TenantId { get; set; }
public required int DefaultDurationSeconds { get; set; }
public required Dictionary<string, int> EventKindOverrides { get; set; }
public int? MaxEventsPerWindow { get; set; }
public int? BurstWindowDurationSeconds { get; set; }
public required bool Enabled { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
public bool IsDefault { get; set; }
}
/// <summary>
/// Response for throttle evaluation.
/// </summary>
public sealed class ThrottleEvaluateApiResponse
{
public required string EventKind { get; set; }
public required int EffectiveDurationSeconds { get; set; }
}
#endregion
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for throttle configuration management.
/// </summary>
public static class ThrottleEndpoints
{
/// <summary>
/// Maps throttle configuration endpoints.
/// </summary>
public static IEndpointRouteBuilder MapThrottleEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/throttles")
.WithTags("Throttles")
.WithOpenApi();
group.MapGet("/config", GetConfigurationAsync)
.WithName("GetThrottleConfiguration")
.WithSummary("Get throttle configuration")
.WithDescription("Returns the throttle configuration for the tenant.");
group.MapPut("/config", UpdateConfigurationAsync)
.WithName("UpdateThrottleConfiguration")
.WithSummary("Update throttle configuration")
.WithDescription("Creates or updates the throttle configuration for the tenant.");
group.MapDelete("/config", DeleteConfigurationAsync)
.WithName("DeleteThrottleConfiguration")
.WithSummary("Delete throttle configuration")
.WithDescription("Deletes the throttle configuration for the tenant, reverting to defaults.");
group.MapPost("/evaluate", EvaluateAsync)
.WithName("EvaluateThrottle")
.WithSummary("Evaluate throttle duration")
.WithDescription("Returns the effective throttle duration for an event kind.");
return app;
}
private static async Task<IResult> GetConfigurationAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var config = await throttleService.GetConfigurationAsync(tenantId, cancellationToken);
if (config is null)
{
return Results.Ok(new ThrottleConfigurationApiResponse
{
TenantId = tenantId,
DefaultDurationSeconds = 900, // 15 minutes default
Enabled = true,
EventKindOverrides = new Dictionary<string, int>(),
IsDefault = true
});
}
return Results.Ok(MapToApiResponse(config));
}
private static async Task<IResult> UpdateConfigurationAsync(
[FromBody] ThrottleConfigurationApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (request.DefaultDurationSeconds is <= 0)
{
return Results.BadRequest(new { error = "Default duration must be a positive value in seconds." });
}
var config = new ThrottleConfiguration
{
TenantId = tenantId,
DefaultDuration = TimeSpan.FromSeconds(request.DefaultDurationSeconds ?? 900),
EventKindOverrides = request.EventKindOverrides?
.ToDictionary(kvp => kvp.Key, kvp => TimeSpan.FromSeconds(kvp.Value)),
MaxEventsPerWindow = request.MaxEventsPerWindow,
BurstWindowDuration = request.BurstWindowDurationSeconds.HasValue
? TimeSpan.FromSeconds(request.BurstWindowDurationSeconds.Value)
: null,
Enabled = request.Enabled ?? true
};
var updated = await throttleService.UpsertConfigurationAsync(config, actor, cancellationToken);
return Results.Ok(MapToApiResponse(updated));
}
private static async Task<IResult> DeleteConfigurationAsync(
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-Actor")] string? actor,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
}
var deleted = await throttleService.DeleteConfigurationAsync(tenantId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(new { error = "No throttle configuration exists for this tenant." });
}
return Results.NoContent();
}
private static async Task<IResult> EvaluateAsync(
[FromBody] ThrottleEvaluateApiRequest request,
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
[FromServices] IThrottleConfigurationService throttleService,
CancellationToken cancellationToken)
{
var tenantId = request.TenantId ?? tenantIdHeader;
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
}
if (string.IsNullOrWhiteSpace(request.EventKind))
{
return Results.BadRequest(new { error = "Event kind is required." });
}
var duration = await throttleService.GetEffectiveThrottleDurationAsync(
tenantId,
request.EventKind,
cancellationToken);
return Results.Ok(new ThrottleEvaluateApiResponse
{
EventKind = request.EventKind,
EffectiveDurationSeconds = (int)duration.TotalSeconds
});
}
private static ThrottleConfigurationApiResponse MapToApiResponse(ThrottleConfiguration config) => new()
{
TenantId = config.TenantId,
DefaultDurationSeconds = (int)config.DefaultDuration.TotalSeconds,
EventKindOverrides = config.EventKindOverrides?
.ToDictionary(kvp => kvp.Key, kvp => (int)kvp.Value.TotalSeconds)
?? new Dictionary<string, int>(),
MaxEventsPerWindow = config.MaxEventsPerWindow,
BurstWindowDurationSeconds = config.BurstWindowDuration.HasValue
? (int)config.BurstWindowDuration.Value.TotalSeconds
: null,
Enabled = config.Enabled,
CreatedAt = config.CreatedAt,
CreatedBy = config.CreatedBy,
UpdatedAt = config.UpdatedAt,
UpdatedBy = config.UpdatedBy,
IsDefault = false
};
}
#region API Request/Response Models
/// <summary>
/// Request to create or update throttle configuration.
/// </summary>
public sealed class ThrottleConfigurationApiRequest
{
public string? TenantId { get; set; }
public int? DefaultDurationSeconds { get; set; }
public Dictionary<string, int>? EventKindOverrides { get; set; }
public int? MaxEventsPerWindow { get; set; }
public int? BurstWindowDurationSeconds { get; set; }
public bool? Enabled { get; set; }
}
/// <summary>
/// Request to evaluate throttle duration.
/// </summary>
public sealed class ThrottleEvaluateApiRequest
{
public string? TenantId { get; set; }
public string? EventKind { get; set; }
}
/// <summary>
/// Response for throttle configuration.
/// </summary>
public sealed class ThrottleConfigurationApiResponse
{
public required string TenantId { get; set; }
public required int DefaultDurationSeconds { get; set; }
public required Dictionary<string, int> EventKindOverrides { get; set; }
public int? MaxEventsPerWindow { get; set; }
public int? BurstWindowDurationSeconds { get; set; }
public required bool Enabled { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
public bool IsDefault { get; set; }
}
/// <summary>
/// Response for throttle evaluation.
/// </summary>
public sealed class ThrottleEvaluateApiResponse
{
public required string EventKind { get; set; }
public required int EffectiveDurationSeconds { get; set; }
}
#endregion

View File

@@ -1,348 +1,348 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Web;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
/// </summary>
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
private static readonly Regex IfBlockPattern = IfBlockRegex();
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
private readonly ILogger<AdvancedTemplateRenderer> _logger;
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
options ??= new TemplateRenderOptions();
try
{
// Process conditional blocks first
body = ProcessIfBlocks(body, payload);
// Process {{#each}} blocks
body = ProcessEachBlocks(body, payload);
// Substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
// Convert to target format based on render mode
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
// Append provenance link if requested
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
}
return body;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
return $"[Render Error: {ex.Message}]";
}
}
private static string ProcessIfBlocks(string body, JsonNode? payload)
{
// Process {{#if condition}}...{{else}}...{{/if}} blocks
return IfBlockPattern.Replace(body, match =>
{
var conditionPath = match.Groups[1].Value.Trim();
var ifContent = match.Groups[2].Value;
var elseMatch = ElseBlockPattern.Match(ifContent);
string trueContent;
string falseContent;
if (elseMatch.Success)
{
trueContent = ifContent[..elseMatch.Index];
falseContent = elseMatch.Groups[1].Value;
}
else
{
trueContent = ifContent;
falseContent = string.Empty;
}
var conditionValue = ResolvePath(payload, conditionPath);
var isTruthy = EvaluateTruthy(conditionValue);
return isTruthy ? trueContent : falseContent;
});
}
private static bool EvaluateTruthy(JsonNode? value)
{
if (value is null)
{
return false;
}
return value switch
{
JsonValue jv when jv.TryGetValue(out bool b) => b,
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
JsonArray arr => arr.Count > 0,
JsonObject obj => obj.Count > 0,
_ => true
};
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is JsonArray arr)
{
var results = new List<string>();
var index = 0;
foreach (var item in arr)
{
var itemResult = innerTemplate
.Replace("{{@index}}", index.ToString())
.Replace("{{this}}", item?.ToString() ?? string.Empty);
// Also substitute nested properties from item
if (item is JsonObject itemObj)
{
itemResult = SubstitutePlaceholders(itemResult, itemObj);
}
results.Add(itemResult);
index++;
}
return string.Join(string.Empty, results);
}
if (collection is JsonObject obj)
{
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
}
return string.Empty;
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
{
// If source is already in the target format family, return as-is
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
{
return body;
}
return targetFormat switch
{
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
_ => body
};
}
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
{
// Wrap content in a JSON structure
var content = new JsonObject
{
["content"] = body,
["format"] = sourceMode.ToString()
};
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
{
// Convert Markdown to Slack mrkdwn format
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Slack uses similar markdown but with some differences
// Convert **bold** to *bold* for Slack
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
}
return body;
}
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
{
// Teams uses Adaptive Cards or MessageCard format
// For simple conversion, wrap in basic card structure
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
sourceMode == NotifyTemplateRenderMode.PlainText)
{
var card = new JsonObject
{
["@type"] = "MessageCard",
["@context"] = "http://schema.org/extensions",
["summary"] = "Notification",
["sections"] = new JsonArray
{
new JsonObject
{
["text"] = body
}
}
};
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
return body;
}
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
{
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Basic Markdown to HTML conversion for email
return ConvertMarkdownToHtml(body);
}
if (sourceMode == NotifyTemplateRenderMode.PlainText)
{
// Wrap plain text in basic HTML structure
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
}
return body;
}
private static string ConvertMarkdownToHtml(string markdown)
{
var html = new StringBuilder(markdown);
// Headers
html.Replace("\n### ", "\n<h3>");
html.Replace("\n## ", "\n<h2>");
html.Replace("\n# ", "\n<h1>");
// Bold
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
// Italic
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
// Code
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
// Links
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
// Line breaks
html.Replace("\n\n", "</p><p>");
html.Replace("\n", "<br/>");
return $"<html><body><p>{html}</p></body></html>";
}
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
{
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
return template.RenderMode switch
{
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
_ => body
};
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex IfBlockRegex();
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex ElseBlockRegex();
}
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Web;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
/// </summary>
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
{
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
private static readonly Regex EachBlockPattern = EachBlockRegex();
private static readonly Regex IfBlockPattern = IfBlockRegex();
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
private readonly ILogger<AdvancedTemplateRenderer> _logger;
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
var body = template.Body;
if (string.IsNullOrWhiteSpace(body))
{
return string.Empty;
}
options ??= new TemplateRenderOptions();
try
{
// Process conditional blocks first
body = ProcessIfBlocks(body, payload);
// Process {{#each}} blocks
body = ProcessEachBlocks(body, payload);
// Substitute simple placeholders
body = SubstitutePlaceholders(body, payload);
// Convert to target format based on render mode
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
// Append provenance link if requested
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
}
return body;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
return $"[Render Error: {ex.Message}]";
}
}
private static string ProcessIfBlocks(string body, JsonNode? payload)
{
// Process {{#if condition}}...{{else}}...{{/if}} blocks
return IfBlockPattern.Replace(body, match =>
{
var conditionPath = match.Groups[1].Value.Trim();
var ifContent = match.Groups[2].Value;
var elseMatch = ElseBlockPattern.Match(ifContent);
string trueContent;
string falseContent;
if (elseMatch.Success)
{
trueContent = ifContent[..elseMatch.Index];
falseContent = elseMatch.Groups[1].Value;
}
else
{
trueContent = ifContent;
falseContent = string.Empty;
}
var conditionValue = ResolvePath(payload, conditionPath);
var isTruthy = EvaluateTruthy(conditionValue);
return isTruthy ? trueContent : falseContent;
});
}
private static bool EvaluateTruthy(JsonNode? value)
{
if (value is null)
{
return false;
}
return value switch
{
JsonValue jv when jv.TryGetValue(out bool b) => b,
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
JsonArray arr => arr.Count > 0,
JsonObject obj => obj.Count > 0,
_ => true
};
}
private static string ProcessEachBlocks(string body, JsonNode? payload)
{
return EachBlockPattern.Replace(body, match =>
{
var collectionPath = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
var collection = ResolvePath(payload, collectionPath);
if (collection is JsonArray arr)
{
var results = new List<string>();
var index = 0;
foreach (var item in arr)
{
var itemResult = innerTemplate
.Replace("{{@index}}", index.ToString())
.Replace("{{this}}", item?.ToString() ?? string.Empty);
// Also substitute nested properties from item
if (item is JsonObject itemObj)
{
itemResult = SubstitutePlaceholders(itemResult, itemObj);
}
results.Add(itemResult);
index++;
}
return string.Join(string.Empty, results);
}
if (collection is JsonObject obj)
{
var results = new List<string>();
foreach (var (key, value) in obj)
{
var itemResult = innerTemplate
.Replace("{{@key}}", key)
.Replace("{{this}}", value?.ToString() ?? string.Empty);
results.Add(itemResult);
}
return string.Join(string.Empty, results);
}
return string.Empty;
});
}
private static string SubstitutePlaceholders(string body, JsonNode? payload)
{
return PlaceholderPattern.Replace(body, match =>
{
var path = match.Groups[1].Value.Trim();
var resolved = ResolvePath(payload, path);
return resolved?.ToString() ?? string.Empty;
});
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root is null || string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.');
var current = root;
foreach (var segment in segments)
{
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
{
current = next;
}
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
{
// If source is already in the target format family, return as-is
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
{
return body;
}
return targetFormat switch
{
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
_ => body
};
}
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
{
// Wrap content in a JSON structure
var content = new JsonObject
{
["content"] = body,
["format"] = sourceMode.ToString()
};
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
{
// Convert Markdown to Slack mrkdwn format
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Slack uses similar markdown but with some differences
// Convert **bold** to *bold* for Slack
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
}
return body;
}
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
{
// Teams uses Adaptive Cards or MessageCard format
// For simple conversion, wrap in basic card structure
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
sourceMode == NotifyTemplateRenderMode.PlainText)
{
var card = new JsonObject
{
["@type"] = "MessageCard",
["@context"] = "http://schema.org/extensions",
["summary"] = "Notification",
["sections"] = new JsonArray
{
new JsonObject
{
["text"] = body
}
}
};
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
return body;
}
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
{
if (sourceMode == NotifyTemplateRenderMode.Markdown)
{
// Basic Markdown to HTML conversion for email
return ConvertMarkdownToHtml(body);
}
if (sourceMode == NotifyTemplateRenderMode.PlainText)
{
// Wrap plain text in basic HTML structure
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
}
return body;
}
private static string ConvertMarkdownToHtml(string markdown)
{
var html = new StringBuilder(markdown);
// Headers
html.Replace("\n### ", "\n<h3>");
html.Replace("\n## ", "\n<h2>");
html.Replace("\n# ", "\n<h1>");
// Bold
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
// Italic
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
// Code
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
// Links
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
// Line breaks
html.Replace("\n\n", "</p><p>");
html.Replace("\n", "<br/>");
return $"<html><body><p>{html}</p></body></html>";
}
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
{
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
return template.RenderMode switch
{
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
_ => body
};
}
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
private static partial Regex PlaceholderRegex();
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex IfBlockRegex();
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
private static partial Regex ElseBlockRegex();
}

View File

@@ -1,201 +1,201 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
/// </summary>
public sealed class DefaultLocalizationResolver : ILocalizationResolver
{
private const string DefaultLocale = "en-us";
private const string DefaultLanguage = "en";
private readonly INotifyLocalizationRepository _repository;
private readonly ILogger<DefaultLocalizationResolver> _logger;
public DefaultLocalizationResolver(
INotifyLocalizationRepository repository,
ILogger<DefaultLocalizationResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LocalizedString?> ResolveAsync(
string tenantId,
string bundleKey,
string stringKey,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
continue;
}
var value = bundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
stringKey, bundleKey, tryLocale, locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = tryLocale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
}
}
// Try the default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null)
{
var value = defaultBundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
stringKey, bundleKey, defaultBundle.Locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = defaultBundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
};
}
}
_logger.LogWarning(
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
return null;
}
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
string tenantId,
string bundleKey,
IEnumerable<string> stringKeys,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentNullException.ThrowIfNull(stringKeys);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
// Load all bundles in the fallback chain
var bundles = new List<NotifyLocalizationBundle>();
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is not null)
{
bundles.Add(bundle);
}
}
// Add default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
{
bundles.Add(defaultBundle);
}
// Resolve each key through the bundles
foreach (var key in keysToResolve)
{
foreach (var bundle in bundles)
{
var value = bundle.GetString(key);
if (value is not null)
{
results[key] = new LocalizedString
{
Value = value,
ResolvedLocale = bundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
break;
}
}
}
return results;
}
/// <summary>
/// Builds a fallback chain for the given locale.
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
/// </summary>
private static IReadOnlyList<string> BuildFallbackChain(string locale)
{
var chain = new List<string> { locale };
// Add language-only fallback (e.g., "pt" from "pt-br")
var dashIndex = locale.IndexOf('-');
if (dashIndex > 0)
{
var languageOnly = locale[..dashIndex];
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
{
chain.Add(languageOnly);
}
}
// Add default locale if not already in chain
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLocale);
}
// Add default language if not already in chain
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLanguage);
}
return chain;
}
private static string NormalizeLocale(string? locale)
{
if (string.IsNullOrWhiteSpace(locale))
{
return DefaultLocale;
}
return locale.ToLowerInvariant().Trim();
}
}
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
/// </summary>
public sealed class DefaultLocalizationResolver : ILocalizationResolver
{
private const string DefaultLocale = "en-us";
private const string DefaultLanguage = "en";
private readonly INotifyLocalizationRepository _repository;
private readonly ILogger<DefaultLocalizationResolver> _logger;
public DefaultLocalizationResolver(
INotifyLocalizationRepository repository,
ILogger<DefaultLocalizationResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LocalizedString?> ResolveAsync(
string tenantId,
string bundleKey,
string stringKey,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
continue;
}
var value = bundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
stringKey, bundleKey, tryLocale, locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = tryLocale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
}
}
// Try the default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null)
{
var value = defaultBundle.GetString(stringKey);
if (value is not null)
{
_logger.LogDebug(
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
stringKey, bundleKey, defaultBundle.Locale);
return new LocalizedString
{
Value = value,
ResolvedLocale = defaultBundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
};
}
}
_logger.LogWarning(
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
return null;
}
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
string tenantId,
string bundleKey,
IEnumerable<string> stringKeys,
string locale,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
ArgumentNullException.ThrowIfNull(stringKeys);
locale = NormalizeLocale(locale);
var fallbackChain = BuildFallbackChain(locale);
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
// Load all bundles in the fallback chain
var bundles = new List<NotifyLocalizationBundle>();
foreach (var tryLocale in fallbackChain)
{
var bundle = await _repository.GetByKeyAndLocaleAsync(
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
if (bundle is not null)
{
bundles.Add(bundle);
}
}
// Add default bundle
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
.ConfigureAwait(false);
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
{
bundles.Add(defaultBundle);
}
// Resolve each key through the bundles
foreach (var key in keysToResolve)
{
foreach (var bundle in bundles)
{
var value = bundle.GetString(key);
if (value is not null)
{
results[key] = new LocalizedString
{
Value = value,
ResolvedLocale = bundle.Locale,
RequestedLocale = locale,
FallbackChain = fallbackChain
};
break;
}
}
}
return results;
}
/// <summary>
/// Builds a fallback chain for the given locale.
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
/// </summary>
private static IReadOnlyList<string> BuildFallbackChain(string locale)
{
var chain = new List<string> { locale };
// Add language-only fallback (e.g., "pt" from "pt-br")
var dashIndex = locale.IndexOf('-');
if (dashIndex > 0)
{
var languageOnly = locale[..dashIndex];
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
{
chain.Add(languageOnly);
}
}
// Add default locale if not already in chain
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLocale);
}
// Add default language if not already in chain
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
{
chain.Add(DefaultLanguage);
}
return chain;
}
private static string NormalizeLocale(string? locale)
{
if (string.IsNullOrWhiteSpace(locale))
{
return DefaultLocale;
}
return locale.ToLowerInvariant().Trim();
}
}

View File

@@ -1,15 +1,15 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Template renderer with support for render options, format conversion, and redaction.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template with the given payload and options.
/// </summary>
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
}
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Template renderer with support for render options, format conversion, and redaction.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template with the given payload and options.
/// </summary>
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
}

View File

@@ -1,102 +1,102 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Application-level service for managing versioned templates with localization support.
/// </summary>
public interface INotifyTemplateService
{
/// <summary>
/// Gets a template by key and locale, falling back to the default locale if not found.
/// </summary>
Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific template by ID.
/// </summary>
Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all templates for a tenant, optionally filtered.
/// </summary>
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a template with version tracking.
/// </summary>
Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a template.
/// </summary>
Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Renders a template preview with sample payload (no persistence).
/// </summary>
Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a template preview render.
/// </summary>
public sealed record TemplatePreviewResult
{
public required string RenderedBody { get; init; }
public required string? RenderedSubject { get; init; }
public required NotifyTemplateRenderMode RenderMode { get; init; }
public required NotifyDeliveryFormat Format { get; init; }
public IReadOnlyList<string> RedactedFields { get; init; } = [];
public string? ProvenanceLink { get; init; }
}
/// <summary>
/// Options for template rendering.
/// </summary>
public sealed record TemplateRenderOptions
{
/// <summary>
/// Fields to redact from the output (dot-notation paths).
/// </summary>
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
/// <summary>
/// Whether to include provenance links in output.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Target format override.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Application-level service for managing versioned templates with localization support.
/// </summary>
public interface INotifyTemplateService
{
/// <summary>
/// Gets a template by key and locale, falling back to the default locale if not found.
/// </summary>
Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific template by ID.
/// </summary>
Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all templates for a tenant, optionally filtered.
/// </summary>
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates or updates a template with version tracking.
/// </summary>
Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a template.
/// </summary>
Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default);
/// <summary>
/// Renders a template preview with sample payload (no persistence).
/// </summary>
Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of a template preview render.
/// </summary>
public sealed record TemplatePreviewResult
{
public required string RenderedBody { get; init; }
public required string? RenderedSubject { get; init; }
public required NotifyTemplateRenderMode RenderMode { get; init; }
public required NotifyDeliveryFormat Format { get; init; }
public IReadOnlyList<string> RedactedFields { get; init; } = [];
public string? ProvenanceLink { get; init; }
}
/// <summary>
/// Options for template rendering.
/// </summary>
public sealed record TemplateRenderOptions
{
/// <summary>
/// Fields to redact from the output (dot-notation paths).
/// </summary>
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
/// <summary>
/// Whether to include provenance links in output.
/// </summary>
public bool IncludeProvenance { get; init; } = true;
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Target format override.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}

View File

@@ -1,273 +1,273 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
/// </summary>
public sealed class NotifyTemplateService : INotifyTemplateService
{
private const string DefaultLocale = "en-us";
private readonly INotifyTemplateRepository _repository;
private readonly INotifyTemplateRenderer _renderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyTemplateService> _logger;
public NotifyTemplateService(
INotifyTemplateRepository repository,
INotifyTemplateRenderer renderer,
TimeProvider timeProvider,
ILogger<NotifyTemplateService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(key);
locale = NormalizeLocale(locale);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Filter by key
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
// Filter by channel type if specified
if (channelType.HasValue)
{
matching = matching.Where(t => t.ChannelType == channelType.Value);
}
var candidates = matching.ToArray();
// Try exact locale match
var exactMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
{
return exactMatch;
}
// Try language-only match (e.g., "en" from "en-us")
var languageCode = locale.Split('-')[0];
var languageMatch = candidates.FirstOrDefault(t =>
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
if (languageMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
key, locale, languageMatch.Locale);
return languageMatch;
}
// Fall back to default locale
var defaultMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
if (defaultMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
key, locale);
return defaultMatch;
}
// Return any available template for the key
return candidates.FirstOrDefault();
}
public Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
return _repository.GetAsync(tenantId, templateId, cancellationToken);
}
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(locale))
{
var normalizedLocale = NormalizeLocale(locale);
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
}
if (channelType.HasValue)
{
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
}
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
}
public async Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
var now = _timeProvider.GetUtcNow();
// Check for existing template to preserve creation metadata
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
.ConfigureAwait(false);
var updatedTemplate = NotifyTemplate.Create(
templateId: template.TemplateId,
tenantId: template.TenantId,
channelType: template.ChannelType,
key: template.Key,
locale: template.Locale,
body: template.Body,
renderMode: template.RenderMode,
format: template.Format,
description: template.Description,
metadata: template.Metadata,
createdBy: existing?.CreatedBy ?? updatedBy,
createdAt: existing?.CreatedAt ?? now,
updatedBy: updatedBy,
updatedAt: now);
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
return updatedTemplate;
}
public async Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
}
public Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
options ??= new TemplateRenderOptions();
// Apply redaction to payload if allowlist is specified
var redactedFields = new List<string>();
var processedPayload = samplePayload;
if (options.RedactionAllowlist is { Count: > 0 })
{
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
}
// Render body
var renderedBody = _renderer.Render(template, processedPayload, options);
// Render subject if present in metadata
string? renderedSubject = null;
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
{
var subjectTemplateObj = NotifyTemplate.Create(
templateId: "subject-preview",
tenantId: template.TenantId,
channelType: template.ChannelType,
key: "subject",
locale: template.Locale,
body: subjectTemplate);
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
}
// Build provenance link if requested
string? provenanceLink = null;
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
}
var result = new TemplatePreviewResult
{
RenderedBody = renderedBody,
RenderedSubject = renderedSubject,
RenderMode = template.RenderMode,
Format = options.FormatOverride ?? template.Format,
RedactedFields = redactedFields,
ProvenanceLink = provenanceLink
};
return Task.FromResult(result);
}
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
{
if (payload is not JsonObject obj)
{
return payload;
}
var result = new JsonObject();
foreach (var (key, value) in obj)
{
if (allowlist.Contains(key))
{
result[key] = value?.DeepClone();
}
else
{
result[key] = "[REDACTED]";
redactedFields.Add(key);
}
}
return result;
}
private static string NormalizeLocale(string? locale)
{
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
}
}
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Services;
/// <summary>
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
/// </summary>
public sealed class NotifyTemplateService : INotifyTemplateService
{
private const string DefaultLocale = "en-us";
private readonly INotifyTemplateRepository _repository;
private readonly INotifyTemplateRenderer _renderer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyTemplateService> _logger;
public NotifyTemplateService(
INotifyTemplateRepository repository,
INotifyTemplateRenderer renderer,
TimeProvider timeProvider,
ILogger<NotifyTemplateService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<NotifyTemplate?> GetByKeyAsync(
string tenantId,
string key,
string locale,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(key);
locale = NormalizeLocale(locale);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Filter by key
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
// Filter by channel type if specified
if (channelType.HasValue)
{
matching = matching.Where(t => t.ChannelType == channelType.Value);
}
var candidates = matching.ToArray();
// Try exact locale match
var exactMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
if (exactMatch is not null)
{
return exactMatch;
}
// Try language-only match (e.g., "en" from "en-us")
var languageCode = locale.Split('-')[0];
var languageMatch = candidates.FirstOrDefault(t =>
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
if (languageMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
key, locale, languageMatch.Locale);
return languageMatch;
}
// Fall back to default locale
var defaultMatch = candidates.FirstOrDefault(t =>
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
if (defaultMatch is not null)
{
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
key, locale);
return defaultMatch;
}
// Return any available template for the key
return candidates.FirstOrDefault();
}
public Task<NotifyTemplate?> GetByIdAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
return _repository.GetAsync(tenantId, templateId, cancellationToken);
}
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
string tenantId,
string? keyPrefix = null,
string? locale = null,
NotifyChannelType? channelType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(locale))
{
var normalizedLocale = NormalizeLocale(locale);
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
}
if (channelType.HasValue)
{
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
}
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
}
public async Task<NotifyTemplate> UpsertAsync(
NotifyTemplate template,
string updatedBy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
var now = _timeProvider.GetUtcNow();
// Check for existing template to preserve creation metadata
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
.ConfigureAwait(false);
var updatedTemplate = NotifyTemplate.Create(
templateId: template.TemplateId,
tenantId: template.TenantId,
channelType: template.ChannelType,
key: template.Key,
locale: template.Locale,
body: template.Body,
renderMode: template.RenderMode,
format: template.Format,
description: template.Description,
metadata: template.Metadata,
createdBy: existing?.CreatedBy ?? updatedBy,
createdAt: existing?.CreatedAt ?? now,
updatedBy: updatedBy,
updatedAt: now);
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
return updatedTemplate;
}
public async Task DeleteAsync(
string tenantId,
string templateId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
}
public Task<TemplatePreviewResult> PreviewAsync(
NotifyTemplate template,
JsonNode? samplePayload,
TemplateRenderOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
options ??= new TemplateRenderOptions();
// Apply redaction to payload if allowlist is specified
var redactedFields = new List<string>();
var processedPayload = samplePayload;
if (options.RedactionAllowlist is { Count: > 0 })
{
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
}
// Render body
var renderedBody = _renderer.Render(template, processedPayload, options);
// Render subject if present in metadata
string? renderedSubject = null;
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
{
var subjectTemplateObj = NotifyTemplate.Create(
templateId: "subject-preview",
tenantId: template.TenantId,
channelType: template.ChannelType,
key: "subject",
locale: template.Locale,
body: subjectTemplate);
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
}
// Build provenance link if requested
string? provenanceLink = null;
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
{
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
}
var result = new TemplatePreviewResult
{
RenderedBody = renderedBody,
RenderedSubject = renderedSubject,
RenderMode = template.RenderMode,
Format = options.FormatOverride ?? template.Format,
RedactedFields = redactedFields,
ProvenanceLink = provenanceLink
};
return Task.FromResult(result);
}
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
{
if (payload is not JsonObject obj)
{
return payload;
}
var result = new JsonObject();
foreach (var (key, value) in obj)
{
if (allowlist.Contains(key))
{
result[key] = value?.DeepClone();
}
else
{
result[key] = "[REDACTED]";
redactedFields.Add(key);
}
}
return result;
}
private static string NormalizeLocale(string? locale)
{
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
}
}

View File

@@ -1,7 +1,7 @@
using System.Text;
namespace StellaOps.Notifier.WebService.Setup;
using System.Text;
namespace StellaOps.Notifier.WebService.Setup;
public sealed class OpenApiDocumentCache
{
private readonly string _document;
@@ -26,11 +26,11 @@ public sealed class OpenApiDocumentCache
_document = File.ReadAllText(path, Encoding.UTF8);
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(_document);
_hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(_document);
_hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
public string Document => _document;
public string Sha256 => _hash;

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Notifier.WebService;
/// <summary>
/// Marker type used for testing/hosting the web application.
/// </summary>
public sealed class WebServiceAssemblyMarker;
namespace StellaOps.Notifier.WebService;
/// <summary>
/// Marker type used for testing/hosting the web application.
/// </summary>
public sealed class WebServiceAssemblyMarker;