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; /// /// 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). /// 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 CreateAsync( HttpRequest request, HubDbContext db, IOptions 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(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 ListAsync( HttpRequest request, HubDbContext db, IOptions 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 CloseAsync( string reference, HttpRequest request, HubDbContext db, IOptions 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(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(); }