using System.Globalization; using System.Security.Cryptography; using System.Text; using SQLInsight.Repository; namespace SQLInsight.Web.Api; /// Body of POST /api/v1/support-cases. public sealed record SupportCaseCreateBody(string? Title, string? Description, string? ContactName, string? ContactEmail, string? ContactPhone, long? FindingId); /// Body of POST /api/v1/support-cases/{id}/close. public sealed record SupportCaseCloseBody(string? Comment); /// /// Support cases through the API, e.g. from an ITSM system: create, list, read and close. The same logic as the /// /support page; the agent sends them to SQLInsight Hub. Writing needs the scope support:write for tokens. /// public static class SupportApiEndpoints { public static void Map(IEndpointRouteBuilder app) { ArgumentNullException.ThrowIfNull(app); var api = app.MapGroup("/api/v1/support-cases").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead(); api.MapPost("", CreateAsync) .WithSummary("Opret en supportsag / Create a support case") .WithDescription("202 Accepted with the local id and status Pending; the case number (SUP-…) follows when the agent has heard from SQLInsight Hub. " + "Idempotency-Key header: the same key returns the same case. Needs scope support:write for tokens."); api.MapGet("", ListAsync).WithSummary("Kundens supportsager / Support cases (status, from, to, limit, continuation)"); api.MapGet("/{id:long}", GetAsync).WithSummary("Én supportsag med status og sagsnummer / One support case"); api.MapPost("/{id:long}/close", CloseAsync) .WithSummary("Luk en supportsag / Close a support case") .WithDescription("Status CloseRequested until the hub has confirmed it, then Closed. Closing a closed case returns 200. Needs scope support:write for tokens."); } private static IResult Forbidden(string detail) => Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt", detail: detail); private static async Task CreateAsync(SupportCaseCreateBody body, HttpContext http, InsightRepository repository, CancellationToken ct) { if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite)) { return Forbidden("API-tokenet mangler scope \"support:write\"."); } var key = http.Request.Headers["Idempotency-Key"].ToString(); var clientRequestId = key.Length == 0 ? Guid.NewGuid() : RequestId(key); var result = await Support.FileAsync(repository, clientRequestId, body.Title, body.Description, body.ContactName, body.ContactEmail, body.ContactPhone, body.FindingId, http.User.Identity?.Name, ct).ConfigureAwait(false); if (result.NotAllowed) { return Forbidden(Support.NotAllowedText); } if (result.Error is { } error) { return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig sag", detail: error); } var row = (await repository.GetSupportCaseAsync(result.CaseId!.Value, ct).ConfigureAwait(false))!; return Results.Accepted($"/api/v1/support-cases/{row.CaseId}", Json(row)); } /// An Idempotency-Key as the request id: a GUID as it is, anything else hashed to one. public static Guid RequestId(string key) { ArgumentNullException.ThrowIfNull(key); return Guid.TryParse(key, out var guid) ? guid : new Guid(SHA256.HashData(Encoding.UTF8.GetBytes(key)).AsSpan(0, 16)); } private static async Task ListAsync(InsightRepository repository, HttpContext http, CancellationToken ct, string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, int? limit = null, string? continuation = null) { var offset = 0; if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset)) { return Results.BadRequest("Ugyldig continuation."); } var size = Math.Clamp(limit ?? 100, 1, 1000); var (cases, more) = await repository.SearchSupportCasesAsync(status, from, to, offset, size, ct).ConfigureAwait(false); if (more) { http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture); } return Results.Ok(cases.Select(Json)); } private static async Task GetAsync(long id, InsightRepository repository, CancellationToken ct) => await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound(); private static async Task CloseAsync(long id, HttpContext http, InsightRepository repository, CancellationToken ct, SupportCaseCloseBody? body = null) { if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite)) { return Forbidden("API-tokenet mangler scope \"support:write\"."); } if (body?.Comment?.Length > Contracts.SupportCaseCloseRequest.MaxComment) { return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig lukning", detail: $"Kommentaren må højst være {Contracts.SupportCaseCloseRequest.MaxComment} tegn."); } var outcome = await repository.RequestSupportCloseAsync(id, http.User.Identity?.Name, body?.Comment, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); if (outcome == SupportCloseOutcome.NotFound) { return Results.NotFound(); } if (outcome == SupportCloseOutcome.Failed) { return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Sagen kan ikke lukkes", detail: "SQLInsight Hub afviste sagen, så den findes ikke hos SEOSoft."); } var row = (await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false))!; return outcome == SupportCloseOutcome.AlreadyClosed ? Results.Ok(Json(row)) : Results.Accepted($"/api/v1/support-cases/{id}", Json(row)); } private static object Json(SupportCaseRow c) => new { Id = c.CaseId, c.Reference, c.Title, c.Status, c.Source, c.FindingId, c.InstanceName, c.RuleId, c.RequestedBy, c.CreatedAt, c.SentAt, c.CloseRequestedAt, c.ClosedAt, c.ClosedBy, Error = c.Status == SupportCaseStatus.Failed ? c.LastError : null, }; }