- 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>
196 lines
8.7 KiB
C#
196 lines
8.7 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using SQLInsight.Contracts;
|
|
using SQLInsight.Hub.Data;
|
|
using SQLInsight.Hub.Support;
|
|
|
|
namespace SQLInsight.Hub.Api;
|
|
|
|
/// <summary>
|
|
/// Support cases for paying customers, through their agent:
|
|
/// POST /api/v1/cases creates a case (idempotent on ClientRequestId) and returns its number;
|
|
/// GET /api/v1/cases lists the customer's cases, also those SEOSoft created, so SQLInsight Web can show their status.
|
|
/// POST /api/v1/cases/{reference}/close closes one of the customer's own cases (idempotent).
|
|
/// </summary>
|
|
public static class CasesEndpoint
|
|
{
|
|
private const int MaxListed = 200;
|
|
|
|
public static void Map(IEndpointRouteBuilder app)
|
|
{
|
|
app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous();
|
|
app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous();
|
|
app.MapPost("/api/v1/cases/{reference}/close", CloseAsync).DisableAntiforgery().AllowAnonymous();
|
|
}
|
|
|
|
internal static async Task<IResult> CreateAsync(
|
|
HttpRequest request, HubDbContext db, IOptions<HubServerOptions> options, SupportNotifier notifier, TimeProvider time, CancellationToken cancellationToken)
|
|
{
|
|
var (license, rejected) = await FindingsEndpoint.AuthorizeAsync(request, db, options.Value, time, cancellationToken).ConfigureAwait(false);
|
|
if (license is null)
|
|
{
|
|
return rejected!;
|
|
}
|
|
|
|
if (!license.IncludesPrioritySupport)
|
|
{
|
|
return FindingsEndpoint.Problem(403, "Licensen giver ikke support", "Supportsager kræver Professional eller Enterprise.");
|
|
}
|
|
|
|
if (request.ContentLength > 64 * 1024)
|
|
{
|
|
return FindingsEndpoint.Problem(413, "For stor", "Højst 64 KB.");
|
|
}
|
|
|
|
SupportCaseRequest? body;
|
|
try
|
|
{
|
|
body = await JsonSerializer.DeserializeAsync<SupportCaseRequest>(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
return FindingsEndpoint.Problem(400, "Ugyldig JSON", ex.Message);
|
|
}
|
|
|
|
if (body is null)
|
|
{
|
|
return FindingsEndpoint.Problem(400, "Ugyldig sag", "Body mangler.");
|
|
}
|
|
|
|
if (body.Validate() is { } invalid)
|
|
{
|
|
return FindingsEndpoint.Problem(422, "Ugyldig sag", invalid);
|
|
}
|
|
|
|
var now = time.GetUtcNow().UtcDateTime;
|
|
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
|
await CustomerRegistration.LockAsync(db, license.Customer, cancellationToken).ConfigureAwait(false);
|
|
var record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false);
|
|
if (record?.RevokedAt is not null)
|
|
{
|
|
return FindingsEndpoint.Problem(403, "Licensen er spærret", record.RevokedReason ?? "Kontakt SQLInsight-support.");
|
|
}
|
|
|
|
var (customer, _) = await CustomerRegistration.EnsureAsync(db, license, record, now, cancellationToken).ConfigureAwait(false);
|
|
var existing = await db.SupportCases.SingleOrDefaultAsync(c => c.CustomerId == customer.Id && c.ClientRequestId == body.ClientRequestId, cancellationToken).ConfigureAwait(false);
|
|
if (existing is not null)
|
|
{
|
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(SupportCaseStore.ToDto(existing));
|
|
}
|
|
|
|
var (created, error) = await SupportCaseStore.AddAsync(db, new SupportCase
|
|
{
|
|
CustomerId = customer.Id,
|
|
Reference = string.Empty,
|
|
Title = body.Title.Trim(),
|
|
Description = Trimmed(body.Description),
|
|
ContactName = body.ContactName.Trim(),
|
|
ContactEmail = body.ContactEmail.Trim(),
|
|
ContactPhone = Trimmed(body.ContactPhone),
|
|
RequestedBy = Trimmed(body.RequestedBy),
|
|
ClientRequestId = body.ClientRequestId,
|
|
Source = SupportCaseSources.Customer,
|
|
CreatedAt = now,
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
if (created is null)
|
|
{
|
|
return FindingsEndpoint.Problem(409, "Sagen kunne ikke oprettes", error!);
|
|
}
|
|
|
|
// The finding the customer created the case from, if the hub has it as active and not yet in a case.
|
|
if (body.InstanceName is { Length: > 0 } instance && body.RuleId is { Length: > 0 } rule && body.Subject is { } subject)
|
|
{
|
|
var finding = await db.Findings.SingleOrDefaultAsync(f => f.Instance!.CustomerId == customer.Id && f.Instance.InstanceKey == instance
|
|
&& f.RuleId == rule && f.Subject == subject && f.ResolvedAt == null && f.SupportCaseId == null, cancellationToken).ConfigureAwait(false);
|
|
if (finding is not null)
|
|
{
|
|
finding.SupportCaseId = created.Id;
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
await notifier.NotifyAsync(created, customer.Name, cancellationToken).ConfigureAwait(false);
|
|
return Results.Created($"/api/v1/cases/{Uri.EscapeDataString(created.Reference)}", SupportCaseStore.ToDto(created));
|
|
}
|
|
|
|
internal static async Task<IResult> ListAsync(
|
|
HttpRequest request, HubDbContext db, IOptions<HubServerOptions> options, TimeProvider time, CancellationToken cancellationToken)
|
|
{
|
|
var (license, rejected) = await FindingsEndpoint.AuthorizeAsync(request, db, options.Value, time, cancellationToken).ConfigureAwait(false);
|
|
if (license is null)
|
|
{
|
|
return rejected!;
|
|
}
|
|
|
|
if (!license.IncludesPrioritySupport)
|
|
{
|
|
return FindingsEndpoint.Problem(403, "Licensen giver ikke support", "Supportsager kræver Professional eller Enterprise.");
|
|
}
|
|
|
|
var cases = await db.SupportCases.AsNoTracking()
|
|
.Where(c => c.Customer!.Name == license.Customer)
|
|
.OrderByDescending(c => c.CreatedAt)
|
|
.Take(MaxListed)
|
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList());
|
|
}
|
|
|
|
internal static async Task<IResult> CloseAsync(
|
|
string reference, HttpRequest request, HubDbContext db, IOptions<HubServerOptions> options, TimeProvider time, CancellationToken cancellationToken)
|
|
{
|
|
var (license, rejected) = await FindingsEndpoint.AuthorizeAsync(request, db, options.Value, time, cancellationToken).ConfigureAwait(false);
|
|
if (license is null)
|
|
{
|
|
return rejected!;
|
|
}
|
|
|
|
if (!license.IncludesPrioritySupport)
|
|
{
|
|
return FindingsEndpoint.Problem(403, "Licensen giver ikke support", "Supportsager kræver Professional eller Enterprise.");
|
|
}
|
|
|
|
SupportCaseCloseRequest body;
|
|
try
|
|
{
|
|
body = request.ContentLength is null or 0
|
|
? new SupportCaseCloseRequest(null, null)
|
|
: await JsonSerializer.DeserializeAsync<SupportCaseCloseRequest>(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false)
|
|
?? new SupportCaseCloseRequest(null, null);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
return FindingsEndpoint.Problem(400, "Ugyldig JSON", ex.Message);
|
|
}
|
|
|
|
if (body.Validate() is { } invalid)
|
|
{
|
|
return FindingsEndpoint.Problem(422, "Ugyldig lukning", invalid);
|
|
}
|
|
|
|
// Only the license's own customer's cases: anyone else's is simply not found.
|
|
var supportCase = await db.SupportCases
|
|
.SingleOrDefaultAsync(c => c.Customer!.Name == license.Customer && c.Reference == reference, cancellationToken).ConfigureAwait(false);
|
|
if (supportCase is null)
|
|
{
|
|
return FindingsEndpoint.Problem(404, "Ukendt sag", $"Sagen {reference} findes ikke.");
|
|
}
|
|
|
|
if (supportCase.IsOpen)
|
|
{
|
|
supportCase.IsOpen = false;
|
|
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
|
|
supportCase.ClosedBy = "Kunde: " + (string.IsNullOrWhiteSpace(body.RequestedBy) ? "ukendt" : body.RequestedBy.Trim());
|
|
supportCase.ClosedComment = Trimmed(body.Comment);
|
|
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return Results.Ok(SupportCaseStore.ToDto(supportCase));
|
|
}
|
|
|
|
private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|