using ControlPlane.Api.Services; using ControlPlane.Core.Models; using System.Text.Json; namespace ControlPlane.Api.Endpoints; public static class PromotionEndpoints { private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public static IEndpointRouteBuilder MapPromotionEndpoints(this IEndpointRouteBuilder app) { var g = app.MapGroup("/api/promotions").WithTags("Promotions"); // GET /api/promotions/ladder — branch status for all 4 ladder branches g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct) => Results.Ok(await svc.GetLadderStatusAsync(ct))); // GET /api/promotions/history g.MapGet("/history", async (PromotionService svc) => Results.Ok(await svc.GetHistoryAsync())); // POST /api/promotions/promote — body: { from, to, requestedBy, note } // Streams SSE log lines then sends {done, promotion} when complete g.MapPost("/promote", async ( HttpContext ctx, PromotionService svc, PromoteRequest req, CancellationToken ct) => { // Validate ladder step var ladder = PromotionService.Ladder; var fi = Array.IndexOf(ladder, req.From); var ti = Array.IndexOf(ladder, req.To); if (fi < 0 || ti < 0 || ti != fi + 1) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync( new { error = $"Invalid promotion step: {req.From} → {req.To}. Must be adjacent in ladder." }, ct); return; } ctx.Response.Headers.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache"; ctx.Response.Headers.Connection = "keep-alive"; var channel = System.Threading.Channels.Channel.CreateUnbounded( new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true }); void OnLine(string line) => channel.Writer.TryWrite(line); var promoteTask = Task.Run(() => svc.PromoteAsync(req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct), ct) .ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default); await foreach (var line in channel.Reader.ReadAllAsync(ct)) { var json = JsonSerializer.Serialize(new { line }, JsonOpts); await ctx.Response.WriteAsync($"data: {json}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } var promotion = await promoteTask; var doneJson = JsonSerializer.Serialize(new { done = true, promotion }, JsonOpts); await ctx.Response.WriteAsync($"data: {doneJson}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); }); return app; } } public record PromoteRequest(string From, string To, string? RequestedBy, string? Note);