- 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>
137 lines
6.5 KiB
C#
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,
|
|
};
|
|
}
|