sqlinsight-pro/src/SQLInsight.Hub/Support/SupportCaseStore.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

75 lines
3.4 KiB
C#

using Microsoft.EntityFrameworkCore;
using SQLInsight.Contracts;
using SQLInsight.Hub.Data;
namespace SQLInsight.Hub.Support;
/// <summary>Creates support cases with a case number that is unique per customer.</summary>
public static class SupportCaseStore
{
private const int Attempts = 5;
/// <summary>The number the next case for the customer gets unless another is typed: SUP-000001, SUP-000002 …</summary>
public static async Task<string> NextReferenceAsync(HubDbContext db, int customerId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(db);
var references = await db.SupportCases.Where(c => c.CustomerId == customerId).Select(c => c.Reference).ToListAsync(cancellationToken).ConfigureAwait(false);
return CaseNumbers.Next(references);
}
/// <summary>
/// Adds <paramref name="draft"/> and saves it. An empty <see cref="SupportCase.Reference"/> gets the next free number;
/// if someone else took it meanwhile, the next one is tried. A typed reference that already exists for the customer
/// is refused. Inside a transaction EF Core rolls back to a savepoint on failure, so retrying is safe there too.
/// </summary>
/// <returns>The saved case, or the reason it was not saved.</returns>
public static async Task<(SupportCase? Case, string? Error)> AddAsync(HubDbContext db, SupportCase draft, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(db);
ArgumentNullException.ThrowIfNull(draft);
draft.Reference = draft.Reference?.Trim() ?? string.Empty;
var typed = draft.Reference.Length > 0;
if (typed && draft.Reference.Length > 32)
{
return (null, "Sagsnummeret må højst være 32 tegn.");
}
for (var attempt = 0; attempt < Attempts; attempt++)
{
if (!typed)
{
draft.Reference = await NextReferenceAsync(db, draft.CustomerId, cancellationToken).ConfigureAwait(false);
}
else if (await db.SupportCases.AnyAsync(c => c.CustomerId == draft.CustomerId && c.Reference == draft.Reference, cancellationToken).ConfigureAwait(false))
{
return (null, $"Sagsnummeret {draft.Reference} findes allerede for kunden.");
}
db.SupportCases.Add(draft);
try
{
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return (draft, null);
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation })
{
db.Entry(draft).State = EntityState.Detached;
if (typed)
{
return (null, $"Sagsnummeret {draft.Reference} findes allerede for kunden.");
}
}
}
return (null, "Der kunne ikke tildeles et sagsnummer. Prøv igen.");
}
public static SupportCaseDto ToDto(SupportCase c)
{
ArgumentNullException.ThrowIfNull(c);
return new SupportCaseDto(c.Reference, c.Title, c.IsOpen, Utc(c.CreatedAt), c.ClosedAt is { } closed ? Utc(closed) : null, c.Source, c.ClientRequestId,
c.ClosedBy, c.ClosedComment);
}
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
}