- Case numbers SUP-000001, SUP-000002 … per customer, unique per customer (new index; a migration step renames any old duplicates first). The portal suggests the next number on the finding page and under the new "Ny sag" on the customer page; an empty field gets the next number, a typed one that is taken is refused. Concurrent creates retry. - SupportCase gains Source (Staff/Customer), description, contact name, e-mail, phone, the customer's Windows user and ClientRequestId. - POST/GET /api/v1/cases for the customer's agent (license with priority support): creates a case idempotently on ClientRequestId, links the finding it came from, lists the customer's cases incl. staff-created. Customer/license creation is shared with the findings endpoint. - Optional mail to support for customer cases (MailKit; prefers AUTH LOGIN as Microsoft 365 needs). A failed mail is logged, never fails the call. deploy-hub.yml writes optional SUPPORT_SMTP_* secrets to .env. - Supportrapport: created, closed, open now, average/median resolution time, oldest open case and customer-created share per customer for a period (default 90 days), plus the cases; CSV for Danish Excel. - Cases page: newest first, customer cases marked, contact/description. - Customer page: findings can be filtered to one instance (?instans=id, ignored for another customer's instance); active findings per instance by severity. - dotnet-ef as a local tool (dotnet-tools.json) for the migration. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
74 lines
3.4 KiB
C#
74 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);
|
|
}
|
|
|
|
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
|
|
}
|