Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -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