sqlinsight-pro/src/SQLInsight.Web/Api/SupportApiEndpoints.cs
Søren Eggert Lundsteen Olsen f3551b1bf0 Support cases through the API: create, read and close
- POST/GET /api/v1/support-cases[/{id}] and POST .../{id}/close in
  SQLInsight Web, sharing the /support form's logic: same checks, finding
  prefill without sensitive evidence, Idempotency-Key as the request id,
  202 Accepted with Location, problem+json 403 without the support:write
  scope or a support license. RequestedBy is the Windows user or
  token:<name>.
- Closing: "Luk sag" on /support and the API record CloseRequested
  (migration 0012, column-level UPDATE for the read-only web login); the
  agent sends it to the hub with backoff and sets Closed when confirmed;
  a pending case is closed once it has a number; an older hub list never
  turns CloseRequested back to Open.
- Hub: POST /api/v1/cases/{reference}/close for the license's own cases
  (others 404), idempotent, ClosedBy/ClosedComment (EF migration
  CaseClosedBy). Portal closing records "SEOSoft: <user>"; the customer
  page shows who closed and why; the support report and CSV get "Lukket af".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:47:58 +02:00

137 lines
6.5 KiB
C#

using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>Body of POST /api/v1/support-cases.</summary>
public sealed record SupportCaseCreateBody(string? Title, string? Description, string? ContactName, string? ContactEmail, string? ContactPhone, long? FindingId);
/// <summary>Body of POST /api/v1/support-cases/{id}/close.</summary>
public sealed record SupportCaseCloseBody(string? Comment);
/// <summary>
/// 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.
/// </summary>
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<IResult> 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));
}
/// <summary>An Idempotency-Key as the request id: a GUID as it is, anything else hashed to one.</summary>
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<IResult> 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<IResult> 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<IResult> 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,
};
}