Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API contracts for delivery history and retry endpoints.
|
||||
/// Sprint: SPRINT_20251229_018b_FE_notification_delivery_audit
|
||||
/// Task: NOTIFY-016
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Response for delivery listing.
|
||||
/// </summary>
|
||||
public sealed record DeliveryListResponse
|
||||
{
|
||||
public required IReadOnlyList<DeliveryResponse> Items { get; init; }
|
||||
public required int Total { get; init; }
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery response.
|
||||
/// </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 ChannelId { get; init; }
|
||||
public string? EventId { get; init; }
|
||||
public string? EventKind { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required IReadOnlyList<DeliveryAttemptResponse> Attempts { get; init; }
|
||||
public required int RetryCount { get; init; }
|
||||
public string? NextRetryAt { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public string? SentAt { get; init; }
|
||||
public string? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual delivery attempt response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryAttemptResponse
|
||||
{
|
||||
public required int AttemptNumber { get; init; }
|
||||
public required string Timestamp { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int? StatusCode { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int? ResponseTimeMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry a failed delivery.
|
||||
/// </summary>
|
||||
public sealed record DeliveryRetryRequest
|
||||
{
|
||||
public string? ForceChannel { get; init; }
|
||||
public bool BypassThrottle { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from retry operation.
|
||||
/// </summary>
|
||||
public sealed record DeliveryRetryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required bool Retried { get; init; }
|
||||
public required int NewAttemptNumber { get; init; }
|
||||
public required string ScheduledAt { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery statistics response.
|
||||
/// </summary>
|
||||
public sealed record DeliveryStatsResponse
|
||||
{
|
||||
public required int TotalSent { get; init; }
|
||||
public required int TotalFailed { get; init; }
|
||||
public required int TotalThrottled { get; init; }
|
||||
public required int TotalPending { get; init; }
|
||||
public required double AvgDeliveryTimeMs { get; init; }
|
||||
public required double SuccessRate { get; init; }
|
||||
public required string Period { get; init; }
|
||||
public required IReadOnlyDictionary<string, ChannelStatsResponse> ByChannel { get; init; }
|
||||
public required IReadOnlyDictionary<string, EventKindStatsResponse> ByEventKind { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics by channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelStatsResponse
|
||||
{
|
||||
public required int Sent { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics by event kind.
|
||||
/// </summary>
|
||||
public sealed record EventKindStatsResponse
|
||||
{
|
||||
public required int Sent { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
}
|
||||
@@ -896,6 +896,146 @@ app.MapGet("/api/v2/notify/deliveries/{deliveryId}", async (
|
||||
: Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context));
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Delivery Retry and Stats (NOTIFY-016)
|
||||
// =============================================
|
||||
|
||||
app.MapPost("/api/v2/notify/deliveries/{deliveryId}/retry", async (
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
StellaOps.Notifier.WebService.Contracts.DeliveryRetryRequest? request,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
|
||||
|
||||
var delivery = await deliveryRepository.GetAsync(tenantId, deliveryId, context.RequestAborted).ConfigureAwait(false);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("not_found", $"Delivery {deliveryId} not found.", context));
|
||||
}
|
||||
|
||||
if (delivery.Status == NotifyDeliveryStatus.Sent || delivery.Status == NotifyDeliveryStatus.Delivered)
|
||||
{
|
||||
return Results.BadRequest(Error("delivery_already_completed", "Cannot retry a completed delivery.", context));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var newAttemptNumber = delivery.Attempts.Length + 1;
|
||||
|
||||
// Create new attempt and update delivery to pending status for retry
|
||||
var newAttempt = new NotifyDeliveryAttempt(now, NotifyDeliveryAttemptStatus.Enqueued);
|
||||
var updatedDelivery = NotifyDelivery.Create(
|
||||
delivery.DeliveryId,
|
||||
delivery.TenantId,
|
||||
delivery.RuleId,
|
||||
delivery.ActionId,
|
||||
delivery.EventId,
|
||||
delivery.Kind,
|
||||
NotifyDeliveryStatus.Pending,
|
||||
"Retry requested",
|
||||
delivery.Rendered,
|
||||
delivery.Attempts.Append(newAttempt),
|
||||
delivery.Metadata,
|
||||
delivery.CreatedAt);
|
||||
|
||||
await deliveryRepository.UpdateAsync(updatedDelivery, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
// Audit the retry
|
||||
try
|
||||
{
|
||||
await auditRepository.AppendAsync(new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = "delivery.retry",
|
||||
EntityId = deliveryId,
|
||||
EntityType = "delivery",
|
||||
Timestamp = now,
|
||||
Payload = System.Text.Json.JsonSerializer.SerializeToNode(new { deliveryId, reason = request?.Reason, forceChannel = request?.ForceChannel }) as System.Text.Json.Nodes.JsonObject
|
||||
}, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* Ignore audit failures */ }
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
deliveryId,
|
||||
retried = true,
|
||||
newAttemptNumber,
|
||||
scheduledAt = now.ToString("O"),
|
||||
message = "Delivery scheduled for retry"
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/deliveries/stats", async (
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveryRepository) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allDeliveries = await deliveryRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
var sent = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered);
|
||||
var failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed);
|
||||
var throttled = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Throttled);
|
||||
var pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending);
|
||||
var total = sent + failed;
|
||||
|
||||
// Calculate average delivery time from attempts that have status codes (indicating completion)
|
||||
var completedAttempts = allDeliveries
|
||||
.Where(d => d.Attempts.Length > 0)
|
||||
.SelectMany(d => d.Attempts)
|
||||
.Where(a => a.StatusCode.HasValue)
|
||||
.ToList();
|
||||
|
||||
var avgDeliveryTime = completedAttempts.Count > 0 ? 0.0 : 0.0; // Response time not tracked in this model
|
||||
|
||||
var byChannel = allDeliveries
|
||||
.GroupBy(d => d.ActionId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new
|
||||
{
|
||||
sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered),
|
||||
failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed)
|
||||
});
|
||||
|
||||
var byEventKind = allDeliveries
|
||||
.GroupBy(d => d.Kind)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new
|
||||
{
|
||||
sent = g.Count(d => d.Status == NotifyDeliveryStatus.Sent || d.Status == NotifyDeliveryStatus.Delivered),
|
||||
failed = g.Count(d => d.Status == NotifyDeliveryStatus.Failed)
|
||||
});
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
totalSent = sent,
|
||||
totalFailed = failed,
|
||||
totalThrottled = throttled,
|
||||
totalPending = pending,
|
||||
avgDeliveryTimeMs = avgDeliveryTime,
|
||||
successRate = total > 0 ? (double)sent / total * 100 : 0,
|
||||
period = "day",
|
||||
byChannel,
|
||||
byEventKind
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Simulation API (NOTIFY-SVC-39-003)
|
||||
// =============================================
|
||||
|
||||
Reference in New Issue
Block a user