using System.Security.Claims; using CareFix.Api.Auth; using CareFix.Api.Fixes; using CareFix.Api.Hospitals; using CareFix.Api.Infrastructure; using CareFix.Api.Integration; using CareFix.Api.Learning; using CareFix.Api.Knowledge; using CareFix.Api.Tickets; using CareFix.Core; using Microsoft.AspNetCore.Mvc; namespace CareFix.Api.Endpoints; public sealed record TextInput(string? Text); public sealed record ReasonInput(string? Reason); public sealed record CodeInput(string Code); public sealed record PasswordInput(string Current, string Next); public sealed record CloseInput(string? Note); public sealed record ActiveInput(bool Active); public sealed record PlaybookFromFixInput(string Title, string? IssueType, string Keywords); public static class ApiEndpoints { public static void MapCareFix(this WebApplication app) { var api = app.MapGroup("/api"); // ---------------- auth api.MapPost("/auth/login", async (LoginInput i, AuthService auth, CancellationToken ct) => Results.Ok(await auth.LoginAsync(i, ct))) .AllowAnonymous().RequireRateLimiting("login"); // Liveness for the monitoring tool. No data, no auth, so it can be polled from outside. api.MapGet("/health", async (HealthService h, CancellationToken ct) => { var (ok, body) = await h.LivenessAsync(ct); return ok ? Results.Ok(body) : Results.Json(body, statusCode: StatusCodes.Status503ServiceUnavailable); }).AllowAnonymous(); var s = api.MapGroup("").RequireAuthorization(); s.MapGet("/me", async (ClaimsPrincipal u, AuthService auth, CancellationToken ct) => Results.Ok(await auth.MeAsync(u.UserId(), ct))); s.MapPost("/me/password", async (PasswordInput i, ClaimsPrincipal u, AuthService auth, CancellationToken ct) => { await auth.ChangePasswordAsync(u.UserId(), i.Current, i.Next, ct); return Results.NoContent(); }); s.MapPost("/me/totp/start", async (ClaimsPrincipal u, AuthService auth, CancellationToken ct) => Results.Ok(await auth.StartTotpAsync(u.UserId(), ct))); s.MapPost("/me/totp/confirm", async (CodeInput i, ClaimsPrincipal u, AuthService auth, CancellationToken ct) => { await auth.ConfirmTotpAsync(u.UserId(), i.Code, ct); return Results.NoContent(); }); // ---------------- hospitals s.MapGet("/hospitals", async (ClaimsPrincipal u, HospitalAdminService h, CancellationToken ct) => Results.Ok(await h.ListAsync(u.UserId(), u.SeesAllHospitals(), ct))); var admin = s.MapGroup("/admin").RequireAuthorization("Admin"); admin.MapPost("/hospitals", async (HospitalInput i, ClaimsPrincipal u, HospitalAdminService h, CancellationToken ct) => Results.Ok(new { hospitalId = await h.SaveAsync(u.UserId(), null, i, ct) })); admin.MapPut("/hospitals/{id:int}", async (int id, HospitalInput i, ClaimsPrincipal u, HospitalAdminService h, CancellationToken ct) => Results.Ok(new { hospitalId = await h.SaveAsync(u.UserId(), id, i, ct) })); admin.MapPut("/hospitals/{id:int}/connection", async (int id, ConnectionInput i, ClaimsPrincipal u, HospitalAdminService h, CancellationToken ct) => { await h.SaveConnectionAsync(u.UserId(), id, i, ct); return Results.NoContent(); }); admin.MapPost("/hospitals/{id:int}/test", async (int id, IHospitalExecutor exec, CancellationToken ct) => Results.Ok(await exec.TestAsync(id, ct))); admin.MapPost("/hospitals/{id:int}/capture-schema", async (int id, ClaimsPrincipal u, HospitalAdminService h, CancellationToken ct) => Results.Ok(await h.CaptureSchemaAsync(u.UserId(), id, ct))); admin.MapPost("/hospitals/{id:int}/agent-key", async (int id, ClaimsPrincipal u, AgentHub hub, CancellationToken ct) => Results.Ok(new { key = await hub.IssueKeyAsync(u.UserId(), id, ct) })); // ---------------- users admin.MapGet("/health", async (HealthService h, CancellationToken ct) => Results.Ok(await h.DetailAsync(ct))); admin.MapPost("/maintenance/run", async (MaintenanceWorker w, CancellationToken ct) => Results.Ok(await w.RunAsync(ct))); admin.MapGet("/users", async (AuthService auth, CancellationToken ct) => Results.Ok(await auth.ListUsersAsync(ct))); admin.MapPost("/users", async (UserInput i, ClaimsPrincipal u, AuthService auth, CancellationToken ct) => Results.Ok(new { userId = await auth.SaveUserAsync(u.UserId(), null, i, ct) })); admin.MapPut("/users/{id:int}", async (int id, UserInput i, ClaimsPrincipal u, AuthService auth, CancellationToken ct) => Results.Ok(new { userId = await auth.SaveUserAsync(u.UserId(), id, i, ct) })); admin.MapPut("/users/{id:int}/hospitals", async (int id, [FromBody] int[] hospitalIds, ClaimsPrincipal u, AuthService auth, CancellationToken ct) => { await auth.SetUserHospitalsAsync(u.UserId(), id, hospitalIds, ct); return Results.NoContent(); }); admin.MapPost("/users/{id:int}/reset-totp", async (int id, ClaimsPrincipal u, AuthService auth, CancellationToken ct) => { await auth.ResetTotpAsync(u.UserId(), id, ct); return Results.NoContent(); }); // ---------------- knowledge base var mgr = s.MapGroup("").RequireAuthorization("Manager"); mgr.MapPost("/kb/import/{kind}", async (string kind, bool? replace, IFormFile file, ClaimsPrincipal u, KbImportService kbi, CancellationToken ct) => { if (file.Length > 20 * 1024 * 1024) throw AppException.BadRequest("CSV files must be under 20 MB."); using var reader = new StreamReader(file.OpenReadStream()); return Results.Ok(await kbi.ImportAsync(u.UserId(), kind, await reader.ReadToEndAsync(ct), replace == true, ct)); }).DisableAntiforgery(); mgr.MapPost("/kb/learn", async (IFormFile file, ClaimsPrincipal u, TicketMiningService m, CancellationToken ct) => { if (file.Length > 20 * 1024 * 1024) throw AppException.BadRequest("CSV files must be under 20 MB."); using var reader = new StreamReader(file.OpenReadStream()); return Results.Ok(new { batchId = await m.UploadAsync(u, file.FileName, await reader.ReadToEndAsync(ct), ct) }); }).DisableAntiforgery(); mgr.MapGet("/kb/learn", async (TicketMiningService m, CancellationToken ct) => Results.Ok(await m.BatchesAsync(ct))); mgr.MapGet("/kb/learn/{batchId:int}/drafts", async (int batchId, TicketMiningService m, CancellationToken ct) => Results.Ok(await m.DraftsAsync(batchId, ct))); mgr.MapPost("/kb/drafts/{draftId:int}/accept", async (int draftId, TicketMiningService.DraftEdit e, ClaimsPrincipal u, TicketMiningService m, CancellationToken ct) => Results.Ok(new { playbookId = await m.AcceptAsync(u, draftId, e, ct) })); mgr.MapPost("/kb/drafts/{draftId:int}/discard", async (int draftId, ClaimsPrincipal u, TicketMiningService m, CancellationToken ct) => { await m.DiscardAsync(u, draftId, ct); return Results.NoContent(); }); mgr.MapGet("/kb/stats", async (KbImportService kbi, CancellationToken ct) => Results.Ok(await kbi.StatsAsync(ct))); mgr.MapGet("/kb/playbooks", async (KbImportService kbi, CancellationToken ct) => Results.Ok(await kbi.PlaybooksAsync(ct))); mgr.MapPost("/kb/playbooks/{id:int}/active", async (int id, ActiveInput i, ClaimsPrincipal u, KbImportService kbi, CancellationToken ct) => { await kbi.SetPlaybookActiveAsync(u.UserId(), id, i.Active, ct); return Results.NoContent(); }); mgr.MapGet("/reports/summary", async (DateTime? from, DateTime? to, ReportService r, CancellationToken ct) => Results.Ok(await r.SummaryAsync(from ?? DateTime.UtcNow.Date.AddDays(-30), to ?? DateTime.UtcNow.Date, ct))); // ---------------- tickets s.MapGet("/tickets", async (string? state, bool? mine, string? q, ClaimsPrincipal u, TicketService t, CancellationToken ct) => Results.Ok(await t.ListAsync(u, state, mine == true, q, ct))); s.MapPost("/tickets", async (NewTicketInput i, ClaimsPrincipal u, TicketService t, CancellationToken ct) => Results.Ok(new { ticketId = await t.CreateAsync(u, i, ct) })); s.MapGet("/tickets/{id:int}", async (int id, ClaimsPrincipal u, TicketService t, CancellationToken ct) => Results.Ok(await t.DetailAsync(u, id, ct))); s.MapPost("/tickets/{id:int}/messages", async (int id, TextInput i, ClaimsPrincipal u, TicketService t, CancellationToken ct) => { await t.PostMessageAsync(u, id, i.Text ?? "", ct); return Results.Accepted(); }); s.MapPost("/tickets/{id:int}/close", async (int id, CloseInput i, ClaimsPrincipal u, TicketService t, CancellationToken ct) => { await t.CloseAsync(u, id, i.Note, ct); return Results.NoContent(); }); // ---------------- fixes s.MapGet("/approvals", async (ClaimsPrincipal u, FixService f, CancellationToken ct) => Results.Ok(await f.PendingForAsync(u, ct))); s.MapPost("/fixes/{id:int}/approve", async (int id, ReasonInput i, ClaimsPrincipal u, FixService f, CancellationToken ct) => Results.Ok(await f.ApproveAsync(u, id, i.Reason, ct))); s.MapPost("/fixes/{id:int}/reject", async (int id, ReasonInput i, ClaimsPrincipal u, FixService f, CancellationToken ct) => { await f.RejectAsync(u, id, i.Reason, ct); return Results.NoContent(); }); s.MapPost("/fixes/{id:int}/consent", async (int id, IFormFile file, ClaimsPrincipal u, FixService f, CancellationToken ct) => Results.Ok(await f.UploadConsentAsync(u, id, file, ct))).DisableAntiforgery(); s.MapGet("/fixes/{id:int}/consent", async (int id, ClaimsPrincipal u, FixService f, CancellationToken ct) => { var (name, data) = await f.GetConsentAsync(u, id, ct); return Results.File(data, "application/octet-stream", name); }); s.MapPost("/fixes/{id:int}/execute", async (int id, ClaimsPrincipal u, FixService f, CancellationToken ct) => Results.Ok(await f.ExecuteAsync(u, id, ct))); s.MapPost("/fixes/{id:int}/rollback", async (int id, ReasonInput i, ClaimsPrincipal u, FixService f, CancellationToken ct) => Results.Ok(await f.RollbackAsync(u, id, i.Reason, ct))); s.MapPost("/fixes/{id:int}/playbook", async (int id, PlaybookFromFixInput i, ClaimsPrincipal u, FixService f, CancellationToken ct) => Results.Ok(new { playbookId = await f.PromoteToPlaybookAsync(u, id, i.Title, i.IssueType, i.Keywords, ct) })); // ---------------- on-prem agent (authenticated by hospital code + agent key, not by user JWT) var agent = api.MapGroup("/agent").AllowAnonymous().RequireRateLimiting("agent"); agent.MapPost("/poll", async (AgentPoll poll, HttpContext ctx, AgentHub hub, CancellationToken ct) => { var hospitalId = await hub.AuthenticateAsync(ctx.Request, ct); var job = await hub.PollAsync(hospitalId, poll, ctx.Connection.RemoteIpAddress?.ToString(), ct); return job is null ? Results.NoContent() : Results.Json(job, AgentProtocol.Json); }); agent.MapPost("/jobs/{jobId:long}/result", async (long jobId, AgentResult result, HttpContext ctx, AgentHub hub, CancellationToken ct) => { var hospitalId = await hub.AuthenticateAsync(ctx.Request, ct); await hub.CompleteAsync(hospitalId, jobId, result, ct); return Results.NoContent(); }); // ---------------- helpdesk integration (shared key, not user JWT) var hd = api.MapGroup("/integrations").AllowAnonymous().RequireRateLimiting("integration"); hd.MapPost("/tickets", async (InboundTicket i, HttpContext ctx, HelpdeskIntegration h, CancellationToken ct) => { h.Authenticate(ctx.Request); return Results.Ok(await h.CreateAsync(i, ct)); }); hd.MapGet("/tickets", async (string hospitalCode, string externalRef, HttpContext ctx, HelpdeskIntegration h, CancellationToken ct) => { h.Authenticate(ctx.Request); return Results.Ok(await h.StatusAsync(hospitalCode, externalRef, ct)); }); api.MapFallback(() => Results.NotFound(new { error = "Unknown API path." })); } }