From 83c247d536954d83e3d9eb80696788896f3e5181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Thu, 1 Oct 2026 11:33:55 +0200 Subject: [PATCH] Hub: support case numbers, customer case API, mail, support report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .forgejo/workflows/deploy-hub.yml | 35 +- docs/hub.md | 22 + dotnet-tools.json | 13 + src/SQLInsight.Contracts/SupportCases.cs | 83 +++ src/SQLInsight.Hub/Api/CasesEndpoint.cs | 142 +++++ .../Api/CustomerRegistration.cs | 42 ++ src/SQLInsight.Hub/Api/FindingsEndpoint.cs | 25 +- src/SQLInsight.Hub/Data/Entities.cs | 16 +- src/SQLInsight.Hub/Data/HubDbContext.cs | 10 + .../20261001090936_SupportCases.Designer.cs | 586 ++++++++++++++++++ .../Migrations/20261001090936_SupportCases.cs | 141 +++++ .../Migrations/HubDbContextModelSnapshot.cs | 50 +- src/SQLInsight.Hub/HubOptions.cs | 3 + src/SQLInsight.Hub/Portal/Pages/Cases.razor | 7 +- .../Portal/Pages/CustomerDetail.razor | 70 ++- .../Portal/Pages/FindingDetail.razor | 9 +- .../Portal/Pages/SupportReportPage.razor | 123 ++++ src/SQLInsight.Hub/Portal/PortalEndpoints.cs | 51 +- src/SQLInsight.Hub/Portal/PortalQueries.cs | 39 +- .../Portal/Shared/CaseDetails.razor | 19 + .../Portal/Shared/FindingTable.razor | 5 +- .../Portal/Shared/PortalLayout.razor | 1 + src/SQLInsight.Hub/Program.cs | 2 + src/SQLInsight.Hub/SQLInsight.Hub.csproj | 1 + src/SQLInsight.Hub/Support/CaseNumbers.cs | 35 ++ .../Support/SupportCaseStore.cs | 74 +++ src/SQLInsight.Hub/Support/SupportNotifier.cs | 134 ++++ src/SQLInsight.Hub/Support/SupportReport.cs | 153 +++++ src/SQLInsight.Hub/wwwroot/app.css | 10 + .../HubPortalSupportTests.cs | 108 ++++ .../HubSupportApiTests.cs | 142 +++++ .../HubSupportLogicTests.cs | 99 +++ 32 files changed, 2201 insertions(+), 49 deletions(-) create mode 100644 dotnet-tools.json create mode 100644 src/SQLInsight.Contracts/SupportCases.cs create mode 100644 src/SQLInsight.Hub/Api/CasesEndpoint.cs create mode 100644 src/SQLInsight.Hub/Api/CustomerRegistration.cs create mode 100644 src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.Designer.cs create mode 100644 src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.cs create mode 100644 src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor create mode 100644 src/SQLInsight.Hub/Portal/Shared/CaseDetails.razor create mode 100644 src/SQLInsight.Hub/Support/CaseNumbers.cs create mode 100644 src/SQLInsight.Hub/Support/SupportCaseStore.cs create mode 100644 src/SQLInsight.Hub/Support/SupportNotifier.cs create mode 100644 src/SQLInsight.Hub/Support/SupportReport.cs create mode 100644 tests/SQLInsight.IntegrationTests/HubPortalSupportTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/HubSupportApiTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/HubSupportLogicTests.cs diff --git a/.forgejo/workflows/deploy-hub.yml b/.forgejo/workflows/deploy-hub.yml index ccd1aea..c869602 100644 --- a/.forgejo/workflows/deploy-hub.yml +++ b/.forgejo/workflows/deploy-hub.yml @@ -9,7 +9,9 @@ # Alle miljøværdier (også IP'er) kommer fra Forgejo repo-secrets. # # Påkrævede secrets: HOST_BIND HOST_PORT DB_SERVER DB_PORT DB_NAME DB_USER DB_PASSWORD KNOWN_PROXY -# Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (offentlig licensnøgle fra keygen) +# Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (ekstra offentlig licensnøgle; k20261001 er indbygget) +# Valgfri: mail til support ved nye kundesager: SUPPORT_SMTP_HOST SUPPORT_SMTP_PORT (587) SUPPORT_SMTP_USER +# SUPPORT_SMTP_PASSWORD SUPPORT_SMTP_FROM SUPPORT_SMTP_TO (kommasepareret) SUPPORT_PORTAL_URL (link i mailen) # ============================================================================= name: deploy hub @@ -59,6 +61,13 @@ jobs: KNOWN_PROXY: ${{ secrets.KNOWN_PROXY }} TRUSTED_KEY_ID: ${{ secrets.TRUSTED_KEY_ID }} TRUSTED_KEY: ${{ secrets.TRUSTED_KEY }} + SUPPORT_SMTP_HOST: ${{ secrets.SUPPORT_SMTP_HOST }} + SUPPORT_SMTP_PORT: ${{ secrets.SUPPORT_SMTP_PORT }} + SUPPORT_SMTP_USER: ${{ secrets.SUPPORT_SMTP_USER }} + SUPPORT_SMTP_PASSWORD: ${{ secrets.SUPPORT_SMTP_PASSWORD }} + SUPPORT_SMTP_FROM: ${{ secrets.SUPPORT_SMTP_FROM }} + SUPPORT_SMTP_TO: ${{ secrets.SUPPORT_SMTP_TO }} + SUPPORT_PORTAL_URL: ${{ secrets.SUPPORT_PORTAL_URL }} run: | set -eu required="HOST_BIND HOST_PORT DB_SERVER DB_PORT DB_NAME DB_USER DB_PASSWORD KNOWN_PROXY" @@ -77,6 +86,12 @@ jobs: *"'"*|*";"*) echo "ERROR: secret $v indeholder ' eller ; – ikke understøttet"; exit 1 ;; esac done + # Mail-værdierne må gerne indeholde ; (fx i et kodeord), men ikke ' + for v in SUPPORT_SMTP_HOST SUPPORT_SMTP_PORT SUPPORT_SMTP_USER SUPPORT_SMTP_PASSWORD SUPPORT_SMTP_FROM SUPPORT_SMTP_TO SUPPORT_PORTAL_URL; do + case "$(printenv "$v" || true)" in + *"'"*) echo "ERROR: secret $v indeholder ' – ikke understøttet"; exit 1 ;; + esac + done tmp="$(mktemp -d)" { @@ -87,8 +102,24 @@ jobs: printf "SQLInsight__Hub__KnownProxies__0='%s'\n" "$KNOWN_PROXY" if [ -n "${TRUSTED_KEY_ID:-}" ] && [ -n "${TRUSTED_KEY:-}" ]; then printf "SQLInsight__Hub__TrustedKeys__%s='%s'\n" "$TRUSTED_KEY_ID" "$TRUSTED_KEY" + fi + # Mail til support, når en kunde opretter en sag (kun når host, afsender og modtager er sat) + if [ -n "${SUPPORT_SMTP_HOST:-}" ] && [ -n "${SUPPORT_SMTP_FROM:-}" ] && [ -n "${SUPPORT_SMTP_TO:-}" ]; then + n="SQLInsight__Hub__Support__Notify__" + printf "%sEnabled='true'\n" "$n" + printf "%sSmtpHost='%s'\n" "$n" "$SUPPORT_SMTP_HOST" + printf "%sSmtpPort='%s'\n" "$n" "${SUPPORT_SMTP_PORT:-587}" + printf "%sFrom='%s'\n" "$n" "$SUPPORT_SMTP_FROM" + [ -z "${SUPPORT_SMTP_USER:-}" ] || printf "%sUserName='%s'\n" "$n" "$SUPPORT_SMTP_USER" + [ -z "${SUPPORT_SMTP_PASSWORD:-}" ] || printf "%sPassword='%s'\n" "$n" "$SUPPORT_SMTP_PASSWORD" + [ -z "${SUPPORT_PORTAL_URL:-}" ] || printf "%sPortalUrl='%s'\n" "$n" "$SUPPORT_PORTAL_URL" + i=0 + for to in $(printf "%s" "$SUPPORT_SMTP_TO" | tr "," " "); do + printf "%sTo__%s='%s'\n" "$n" "$i" "$to" + i=$((i + 1)) + done else - echo "WARNING: TRUSTED_KEY_ID/TRUSTED_KEY ikke sat – hubben kan ikke verificere licenser" >&2 + echo "NOTE: SUPPORT_SMTP_* ikke sat – nye kundesager vises kun i portalen (ingen mail)" >&2 fi } > "$tmp/.env" cp deploy/hub/docker-compose.prod.yml "$tmp/docker-compose.yml" diff --git a/docs/hub.md b/docs/hub.md index 48443c6..c26ffbf 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -61,6 +61,28 @@ Hubben registrerer, når en instans skifter build, og beregner, hvilke fund der Regler om selve versionen (SQLI-VER-*) er udeladt, fordi de altid forsvinder ved en opdatering. +## Supportsager + +**Sagsnumre** tildeles pr. kunde: `SUP-000001`, `SUP-000002` … Portalen foreslår det næste nummer, når en sag oprettes fra et fund eller under **Ny sag** på kundens side. Feltet kan rettes (fx til et nummer fra et andet system), men et nummer kan kun bruges én gang pr. kunde. Håndskrevne numre, der ikke har formen `SUP-`, påvirker ikke rækkefølgen. + +**Kundens egne sager:** betalende kunder (Professional/Enterprise) opretter sager i SQLInsight Web (se [support.md](support.md)). Agenten sender dem til `POST /api/v1/cases` med licensen og henter status for kundens sager med `GET /api/v1/cases`. Kundeoprettede sager er markeret *Kunde* under **Supportsager** med kontaktperson og beskrivelse, og er knyttet til fundet, hvis sagen blev oprettet fra et. + +**Mail til support,** når en kunde opretter en sag (valgfri). Uden indstillingerne vises nye sager kun i portalen. Med Docker Compose/Forgejo sættes de som repo-secrets (`SUPPORT_SMTP_HOST`, `SUPPORT_SMTP_PORT`, `SUPPORT_SMTP_USER`, `SUPPORT_SMTP_PASSWORD`, `SUPPORT_SMTP_FROM`, `SUPPORT_SMTP_TO` kommasepareret, `SUPPORT_PORTAL_URL`), ellers i `appsettings.json`: + +```json +"Support": { + "Notify": { + "Enabled": true, "SmtpHost": "smtp.office365.com", "SmtpPort": 587, "StartTls": true, + "UserName": "support@seosoft.dk", "Password": "…", "From": "support@seosoft.dk", + "To": [ "support@seosoft.dk" ], "PortalUrl": "https://hub.sqlinsight.seosoft.dk/" + } +} +``` + +Microsoft 365 kræver, at *Authenticated SMTP* er slået til for postkassen; hubben logger ind med AUTH LOGIN. Svar på mailen går til kundens kontaktperson. En mail, der ikke kan sendes, logges; sagen er gemt alligevel. + +**Supportrapport** (portalen → **Supportrapport**): for en periode (standard de seneste 90 dage) og alle kunder eller én: oprettede, lukkede og åbne sager, gennemsnitlig og median løsningstid (fra oprettelse til lukning), ældste åbne sag og andelen, kunden selv har oprettet, samt alle sager i perioden. **Hent CSV** giver listen til Excel (UTF-8, semikolon). + ## Licenser ```bash diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..5c1165b --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.12", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/SQLInsight.Contracts/SupportCases.cs b/src/SQLInsight.Contracts/SupportCases.cs new file mode 100644 index 0000000..edf5b74 --- /dev/null +++ b/src/SQLInsight.Contracts/SupportCases.cs @@ -0,0 +1,83 @@ +namespace SQLInsight.Contracts; + +/// +/// A support case a paying customer creates in SQLInsight Web, sent by the agent to POST /api/v1/cases. +/// makes the request idempotent: a retry returns the case already created. +/// The finding fields are optional; when given, the hub links the customer's matching active finding to the case. +/// +public sealed record SupportCaseRequest( + Guid ClientRequestId, + string Title, + string? Description, + string ContactName, + string ContactEmail, + string? ContactPhone, + string? RequestedBy, + string? InstanceName = null, + string? RuleId = null, + string? Subject = null) +{ + public const int MaxTitle = 200; + public const int MaxDescription = 8000; + public const int MaxContactName = 200; + public const int MaxContactEmail = 320; + public const int MaxContactPhone = 50; + public const int MaxRequestedBy = 256; + public const int MaxInstanceName = 128; + public const int MaxRuleId = 50; + public const int MaxSubject = 400; + + /// Why the request cannot be accepted, or null when it is valid. + public string? Validate() + { + if (ClientRequestId == Guid.Empty) + { + return "ClientRequestId mangler."; + } + + if (string.IsNullOrWhiteSpace(Title) || Title.Length > MaxTitle) + { + return $"Titel skal udfyldes (højst {MaxTitle} tegn)."; + } + + if (string.IsNullOrWhiteSpace(ContactName) || ContactName.Length > MaxContactName) + { + return $"Kontaktperson skal udfyldes (højst {MaxContactName} tegn)."; + } + + if (string.IsNullOrWhiteSpace(ContactEmail) || ContactEmail.Length > MaxContactEmail || !ContactEmail.Contains('@', StringComparison.Ordinal)) + { + return "E-mail skal være en gyldig adresse."; + } + + return (Description?.Length > MaxDescription, ContactPhone?.Length > MaxContactPhone, RequestedBy?.Length > MaxRequestedBy, + InstanceName?.Length > MaxInstanceName, RuleId?.Length > MaxRuleId, Subject?.Length > MaxSubject) switch + { + (true, _, _, _, _, _) => $"Beskrivelsen må højst være {MaxDescription} tegn.", + (_, true, _, _, _, _) => $"Telefonnummeret må højst være {MaxContactPhone} tegn.", + (_, _, true, _, _, _) => "Brugernavnet er for langt.", + (_, _, _, true, _, _) or (_, _, _, _, true, _) or (_, _, _, _, _, true) => "Fundets oplysninger er for lange.", + _ => null, + }; + } +} + +/// A support case as the hub reports it to the customer's agent (POST and GET /api/v1/cases). +public sealed record SupportCaseDto( + string Reference, + string Title, + bool IsOpen, + DateTimeOffset CreatedAt, + DateTimeOffset? ClosedAt, + string Source, + Guid? ClientRequestId); + +/// Who created a support case. +public static class SupportCaseSources +{ + /// SEOSoft staff, in the hub portal. + public const string Staff = "Staff"; + + /// The customer, in SQLInsight Web. + public const string Customer = "Customer"; +} diff --git a/src/SQLInsight.Hub/Api/CasesEndpoint.cs b/src/SQLInsight.Hub/Api/CasesEndpoint.cs new file mode 100644 index 0000000..40b87c9 --- /dev/null +++ b/src/SQLInsight.Hub/Api/CasesEndpoint.cs @@ -0,0 +1,142 @@ +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. +/// +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(); + } + + 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()); + } + + private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/SQLInsight.Hub/Api/CustomerRegistration.cs b/src/SQLInsight.Hub/Api/CustomerRegistration.cs new file mode 100644 index 0000000..12cdef0 --- /dev/null +++ b/src/SQLInsight.Hub/Api/CustomerRegistration.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using SQLInsight.Hub.Data; +using SQLInsight.Licensing; + +namespace SQLInsight.Hub.Api; + +/// +/// The customer and license record behind a verified license, created on first contact. Callers hold the +/// customer's advisory lock () in their transaction, so two first requests do not race. +/// +internal static class CustomerRegistration +{ + /// Serializes everything for one customer until the transaction ends. + public static Task LockAsync(HubDbContext db, string customer, CancellationToken cancellationToken) => + db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({customer}, 0))", cancellationToken); + + /// Finds or creates the customer and the license record, and marks the license as seen now. + public static async Task<(Customer Customer, LicenseRecord Record)> EnsureAsync( + HubDbContext db, License license, LicenseRecord? record, DateTime now, CancellationToken cancellationToken) + { + var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == license.Customer, cancellationToken).ConfigureAwait(false); + if (customer is null) + { + customer = new Customer { Name = license.Customer, CreatedAt = now }; + db.Customers.Add(customer); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + if (record is null) + { + record = new LicenseRecord + { + LicenseId = license.LicenseId, CustomerId = customer.Id, Plan = license.Plan.ToString(), IssuedAt = license.IssuedAt.UtcDateTime, + ExpiresAt = license.ExpiresAt.UtcDateTime, MaxInstances = license.MaxInstances, FirstSeenAt = now, LastSeenAt = now, + }; + db.Licenses.Add(record); + } + + record.LastSeenAt = now; + return (customer, record); + } +} diff --git a/src/SQLInsight.Hub/Api/FindingsEndpoint.cs b/src/SQLInsight.Hub/Api/FindingsEndpoint.cs index 7d52323..27b63ba 100644 --- a/src/SQLInsight.Hub/Api/FindingsEndpoint.cs +++ b/src/SQLInsight.Hub/Api/FindingsEndpoint.cs @@ -92,7 +92,7 @@ public static class FindingsEndpoint // instance (checked against the license's limit) must not race. Everything read before this point is re-read. var now = time.GetUtcNow().UtcDateTime; await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); - await db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({license.Customer}, 0))", cancellationToken).ConfigureAwait(false); + await CustomerRegistration.LockAsync(db, license.Customer, cancellationToken).ConfigureAwait(false); record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false); if (record?.RevokedAt is not null) { @@ -104,25 +104,8 @@ public static class FindingsEndpoint return Results.Ok(new SubmissionReceipt(submission.SubmissionId, Duplicate: true, ActiveFindings: -1)); } - var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == license.Customer, cancellationToken).ConfigureAwait(false); - if (customer is null) - { - customer = new Customer { Name = license.Customer, CreatedAt = now }; - db.Customers.Add(customer); - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - } - - if (record is null) - { - record = new LicenseRecord - { - LicenseId = license.LicenseId, CustomerId = customer.Id, Plan = license.Plan.ToString(), IssuedAt = license.IssuedAt.UtcDateTime, - ExpiresAt = license.ExpiresAt.UtcDateTime, MaxInstances = license.MaxInstances, FirstSeenAt = now, LastSeenAt = now, - }; - db.Licenses.Add(record); - } - - record.LastSeenAt = now; + Customer customer; + (customer, record) = await CustomerRegistration.EnsureAsync(db, license, record, now, cancellationToken).ConfigureAwait(false); var instance = await db.Instances.SingleOrDefaultAsync(i => i.CustomerId == customer.Id && i.InstanceKey == submission.Instance.InstanceKey, cancellationToken).ConfigureAwait(false); if (instance is null) @@ -233,5 +216,5 @@ public static class FindingsEndpoint return seen.Count; } - private static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title); + internal static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title); } diff --git a/src/SQLInsight.Hub/Data/Entities.cs b/src/SQLInsight.Hub/Data/Entities.cs index 99cc380..2cd64aa 100644 --- a/src/SQLInsight.Hub/Data/Entities.cs +++ b/src/SQLInsight.Hub/Data/Entities.cs @@ -96,7 +96,7 @@ public sealed class SupportCase public int CustomerId { get; set; } public Customer? Customer { get; set; } - /// The case number in the support system, e.g. "SUP-1234". + /// The case number, unique per customer. Suggested as SUP-000001, SUP-000002 … (see ). public required string Reference { get; set; } public required string Title { get; set; } @@ -105,6 +105,20 @@ public sealed class SupportCase public DateTime CreatedAt { get; set; } public DateTime? ClosedAt { get; set; } public List Findings { get; } = []; + + /// Who created it: or . + public string Source { get; set; } = Contracts.SupportCaseSources.Staff; + + public string? Description { get; set; } + public string? ContactName { get; set; } + public string? ContactEmail { get; set; } + public string? ContactPhone { get; set; } + + /// The customer's Windows user who created the case in SQLInsight Web. + public string? RequestedBy { get; set; } + + /// The agent's id for a customer-created case; a retried request returns the same case. + public Guid? ClientRequestId { get; set; } } /// A vendor employee who may use the portal. diff --git a/src/SQLInsight.Hub/Data/HubDbContext.cs b/src/SQLInsight.Hub/Data/HubDbContext.cs index 85b67bb..17fb846 100644 --- a/src/SQLInsight.Hub/Data/HubDbContext.cs +++ b/src/SQLInsight.Hub/Data/HubDbContext.cs @@ -59,6 +59,16 @@ public sealed class HubDbContext(DbContextOptions options) : DbCon modelBuilder.Entity(e => { e.HasOne(c => c.Customer).WithMany(c => c.SupportCases).HasForeignKey(c => c.CustomerId); + // Case numbers run per customer, so two customers can both have SUP-000001. + e.HasIndex(c => new { c.CustomerId, c.Reference }).IsUnique(); + e.HasIndex(c => new { c.CustomerId, c.ClientRequestId }).IsUnique().HasFilter("client_request_id IS NOT NULL"); + e.HasIndex(c => c.CreatedAt); + e.Property(c => c.Source).HasMaxLength(16).HasDefaultValue(Contracts.SupportCaseSources.Staff); + e.Property(c => c.Description).HasMaxLength(Contracts.SupportCaseRequest.MaxDescription); + e.Property(c => c.ContactName).HasMaxLength(Contracts.SupportCaseRequest.MaxContactName); + e.Property(c => c.ContactEmail).HasMaxLength(Contracts.SupportCaseRequest.MaxContactEmail); + e.Property(c => c.ContactPhone).HasMaxLength(Contracts.SupportCaseRequest.MaxContactPhone); + e.Property(c => c.RequestedBy).HasMaxLength(Contracts.SupportCaseRequest.MaxRequestedBy); }); modelBuilder.Entity(e => diff --git a/src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.Designer.cs b/src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.Designer.cs new file mode 100644 index 0000000..a22b7d5 --- /dev/null +++ b/src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.Designer.cs @@ -0,0 +1,586 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SQLInsight.Hub.Data; + +#nullable disable + +namespace SQLInsight.Hub.Data.Migrations +{ + [DbContext(typeof(HubDbContext))] + [Migration("20261001090936_SupportCases")] + partial class SupportCases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_customers"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_customers_name"); + + b.ToTable("customers", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence_json"); + + b.Property("Explanation") + .IsRequired() + .HasColumnType("text") + .HasColumnName("explanation"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at"); + + b.Property("InstanceId") + .HasColumnType("integer") + .HasColumnName("instance_id"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("Recommendation") + .IsRequired() + .HasColumnType("text") + .HasColumnName("recommendation"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("RuleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rule_id"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("SupportCaseId") + .HasColumnType("integer") + .HasColumnName("support_case_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_findings"); + + b.HasIndex("SupportCaseId") + .HasDatabaseName("ix_findings_support_case_id"); + + b.HasIndex("Severity", "ResolvedAt") + .HasDatabaseName("ix_findings_severity_resolved_at"); + + b.HasIndex("InstanceId", "RuleId", "Subject") + .IsUnique() + .HasDatabaseName("ix_findings_instance_id_rule_id_subject") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("findings", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentVersion") + .HasColumnType("text") + .HasColumnName("agent_version"); + + b.Property("CustomerId") + .HasColumnType("integer") + .HasColumnName("customer_id"); + + b.Property("Edition") + .HasColumnType("text") + .HasColumnName("edition"); + + b.Property("InstanceKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_key"); + + b.Property("LastCollectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_collected_at"); + + b.Property("LastSubmissionAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_submission_at"); + + b.Property("ProductLevel") + .HasColumnType("text") + .HasColumnName("product_level"); + + b.Property("ProductUpdateLevel") + .HasColumnType("text") + .HasColumnName("product_update_level"); + + b.Property("ProductVersion") + .HasColumnType("text") + .HasColumnName("product_version"); + + b.Property("Release") + .HasColumnType("text") + .HasColumnName("release"); + + b.Property("ServerName") + .HasColumnType("text") + .HasColumnName("server_name"); + + b.HasKey("Id") + .HasName("pk_instances"); + + b.HasIndex("CustomerId", "InstanceKey") + .IsUnique() + .HasDatabaseName("ix_instances_customer_id_instance_key"); + + b.ToTable("instances", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.InstanceVersionChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("changed_at"); + + b.Property("FromVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("from_version"); + + b.Property("InstanceId") + .HasColumnType("integer") + .HasColumnName("instance_id"); + + b.Property("ToLevel") + .HasColumnType("text") + .HasColumnName("to_level"); + + b.Property("ToVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("to_version"); + + b.HasKey("Id") + .HasName("pk_version_changes"); + + b.HasIndex("InstanceId", "ChangedAt") + .HasDatabaseName("ix_version_changes_instance_id_changed_at"); + + b.ToTable("version_changes", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b => + { + b.Property("LicenseId") + .HasColumnType("text") + .HasColumnName("license_id"); + + b.Property("CustomerId") + .HasColumnType("integer") + .HasColumnName("customer_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("MaxInstances") + .HasColumnType("integer") + .HasColumnName("max_instances"); + + b.Property("Plan") + .IsRequired() + .HasColumnType("text") + .HasColumnName("plan"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasColumnType("text") + .HasColumnName("revoked_reason"); + + b.HasKey("LicenseId") + .HasName("pk_licenses"); + + b.HasIndex("CustomerId") + .HasDatabaseName("ix_licenses_customer_id"); + + b.ToTable("licenses", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.RuleState", b => + { + b.Property("InstanceId") + .HasColumnType("integer") + .HasColumnName("instance_id"); + + b.Property("RuleId") + .HasColumnType("text") + .HasColumnName("rule_id"); + + b.Property("Outcome") + .IsRequired() + .HasColumnType("text") + .HasColumnName("outcome"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("InstanceId", "RuleId") + .HasName("pk_rule_states"); + + b.ToTable("rule_states", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.StaffUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_staff_users"); + + b.HasIndex("UserName") + .IsUnique() + .HasDatabaseName("ix_staff_users_user_name"); + + b.ToTable("staff_users", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.Submission", b => + { + b.Property("SubmissionId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("submission_id"); + + b.Property("Applied") + .HasColumnType("boolean") + .HasColumnName("applied"); + + b.Property("CollectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("collected_at"); + + b.Property("FindingCount") + .HasColumnType("integer") + .HasColumnName("finding_count"); + + b.Property("InstanceId") + .HasColumnType("integer") + .HasColumnName("instance_id"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.HasKey("SubmissionId") + .HasName("pk_submissions"); + + b.HasIndex("ReceivedAt") + .HasDatabaseName("ix_submissions_received_at"); + + b.ToTable("submissions", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientRequestId") + .HasColumnType("uuid") + .HasColumnName("client_request_id"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("ContactEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("contact_email"); + + b.Property("ContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("contact_name"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("contact_phone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CustomerId") + .HasColumnType("integer") + .HasColumnName("customer_id"); + + b.Property("Description") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("description"); + + b.Property("IsOpen") + .HasColumnType("boolean") + .HasColumnName("is_open"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference"); + + b.Property("RequestedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("Staff") + .HasColumnName("source"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_support_cases"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_support_cases_created_at"); + + b.HasIndex("CustomerId", "ClientRequestId") + .IsUnique() + .HasDatabaseName("ix_support_cases_customer_id_client_request_id") + .HasFilter("client_request_id IS NOT NULL"); + + b.HasIndex("CustomerId", "Reference") + .IsUnique() + .HasDatabaseName("ix_support_cases_customer_id_reference"); + + b.ToTable("support_cases", (string)null); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b => + { + b.HasOne("SQLInsight.Hub.Data.Instance", "Instance") + .WithMany("Findings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_findings_instances_instance_id"); + + b.HasOne("SQLInsight.Hub.Data.SupportCase", "SupportCase") + .WithMany("Findings") + .HasForeignKey("SupportCaseId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_findings_support_cases_support_case_id"); + + b.Navigation("Instance"); + + b.Navigation("SupportCase"); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b => + { + b.HasOne("SQLInsight.Hub.Data.Customer", "Customer") + .WithMany("Instances") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_instances_customers_customer_id"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.InstanceVersionChange", b => + { + b.HasOne("SQLInsight.Hub.Data.Instance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_version_changes_instances_instance_id"); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b => + { + b.HasOne("SQLInsight.Hub.Data.Customer", "Customer") + .WithMany("Licenses") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_licenses_customers_customer_id"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b => + { + b.HasOne("SQLInsight.Hub.Data.Customer", "Customer") + .WithMany("SupportCases") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_support_cases_customers_customer_id"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b => + { + b.Navigation("Instances"); + + b.Navigation("Licenses"); + + b.Navigation("SupportCases"); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b => + { + b.Navigation("Findings"); + }); + + modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b => + { + b.Navigation("Findings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.cs b/src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.cs new file mode 100644 index 0000000..4e880c1 --- /dev/null +++ b/src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.cs @@ -0,0 +1,141 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SQLInsight.Hub.Data.Migrations +{ + /// + public partial class SupportCases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_support_cases_customer_id", + table: "support_cases"); + + migrationBuilder.AddColumn( + name: "client_request_id", + table: "support_cases", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "contact_email", + table: "support_cases", + type: "character varying(320)", + maxLength: 320, + nullable: true); + + migrationBuilder.AddColumn( + name: "contact_name", + table: "support_cases", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "contact_phone", + table: "support_cases", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "description", + table: "support_cases", + type: "character varying(8000)", + maxLength: 8000, + nullable: true); + + migrationBuilder.AddColumn( + name: "requested_by", + table: "support_cases", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "source", + table: "support_cases", + type: "character varying(16)", + maxLength: 16, + nullable: false, + defaultValue: "Staff"); + + migrationBuilder.CreateIndex( + name: "ix_support_cases_created_at", + table: "support_cases", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "ix_support_cases_customer_id_client_request_id", + table: "support_cases", + columns: new[] { "customer_id", "client_request_id" }, + unique: true, + filter: "client_request_id IS NOT NULL"); + + // Case numbers were free text before: give any duplicate within a customer a suffix so the unique index can be made. + migrationBuilder.Sql(""" + UPDATE support_cases s SET reference = left(s.reference, 20) || '-' || s.id + WHERE EXISTS (SELECT 1 FROM support_cases d WHERE d.customer_id = s.customer_id AND d.reference = s.reference AND d.id < s.id); + """); + + migrationBuilder.CreateIndex( + name: "ix_support_cases_customer_id_reference", + table: "support_cases", + columns: new[] { "customer_id", "reference" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_support_cases_created_at", + table: "support_cases"); + + migrationBuilder.DropIndex( + name: "ix_support_cases_customer_id_client_request_id", + table: "support_cases"); + + migrationBuilder.DropIndex( + name: "ix_support_cases_customer_id_reference", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "client_request_id", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "contact_email", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "contact_name", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "contact_phone", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "description", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "requested_by", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "source", + table: "support_cases"); + + migrationBuilder.CreateIndex( + name: "ix_support_cases_customer_id", + table: "support_cases", + column: "customer_id"); + } + } +} diff --git a/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs b/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs index 263e0dd..1f638b7 100644 --- a/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs +++ b/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs @@ -406,10 +406,29 @@ namespace SQLInsight.Hub.Data.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ClientRequestId") + .HasColumnType("uuid") + .HasColumnName("client_request_id"); + b.Property("ClosedAt") .HasColumnType("timestamp with time zone") .HasColumnName("closed_at"); + b.Property("ContactEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("contact_email"); + + b.Property("ContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("contact_name"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("contact_phone"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); @@ -418,6 +437,11 @@ namespace SQLInsight.Hub.Data.Migrations .HasColumnType("integer") .HasColumnName("customer_id"); + b.Property("Description") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("description"); + b.Property("IsOpen") .HasColumnType("boolean") .HasColumnName("is_open"); @@ -431,6 +455,19 @@ namespace SQLInsight.Hub.Data.Migrations .HasColumnType("text") .HasColumnName("reference"); + b.Property("RequestedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("requested_by"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("Staff") + .HasColumnName("source"); + b.Property("Title") .IsRequired() .HasColumnType("text") @@ -439,8 +476,17 @@ namespace SQLInsight.Hub.Data.Migrations b.HasKey("Id") .HasName("pk_support_cases"); - b.HasIndex("CustomerId") - .HasDatabaseName("ix_support_cases_customer_id"); + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_support_cases_created_at"); + + b.HasIndex("CustomerId", "ClientRequestId") + .IsUnique() + .HasDatabaseName("ix_support_cases_customer_id_client_request_id") + .HasFilter("client_request_id IS NOT NULL"); + + b.HasIndex("CustomerId", "Reference") + .IsUnique() + .HasDatabaseName("ix_support_cases_customer_id_reference"); b.ToTable("support_cases", (string)null); }); diff --git a/src/SQLInsight.Hub/HubOptions.cs b/src/SQLInsight.Hub/HubOptions.cs index 8701374..8c8065d 100644 --- a/src/SQLInsight.Hub/HubOptions.cs +++ b/src/SQLInsight.Hub/HubOptions.cs @@ -38,6 +38,9 @@ public sealed class HubServerOptions /// Upgrade insights shared with agents (aggregated, anonymous). public Insights.InsightOptions Insights { get; set; } = new(); + + /// Support cases from customers: e-mail to SEOSoft when one is created. + public Support.SupportOptions Support { get; set; } = new(); } /// How long the hub keeps data it no longer needs. Applied once a day. diff --git a/src/SQLInsight.Hub/Portal/Pages/Cases.razor b/src/SQLInsight.Hub/Portal/Pages/Cases.razor index 55837f8..0049934 100644 --- a/src/SQLInsight.Hub/Portal/Pages/Cases.razor +++ b/src/SQLInsight.Hub/Portal/Pages/Cases.razor @@ -4,6 +4,7 @@ Supportsager · SQLInsight Hub

Åbne supportsager

+

Nyeste øverst. Sager, kunden selv har oprettet i SQLInsight Web, er markeret Kunde. Supportrapport →

@if (_cases.Count == 0) { @@ -17,12 +18,12 @@ @foreach (var c in _cases) { - @c.Reference + @c.Reference @if (c.Source == SQLInsight.Contracts.SupportCaseSources.Customer) { Kunde } @c.Customer!.Name - @c.Title + @c.Title @c.Findings.Count @c.Findings.Count(f => f.ResolvedAt == null) - @c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + @c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC
} diff --git a/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor index 3062f9d..f0dddd5 100644 --- a/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor +++ b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor @@ -17,12 +17,30 @@

Instanser

- + @foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey)) { - - + var selected = i.Id == _instance?.Id; + + + @@ -33,8 +51,14 @@
-

Aktive fund

- +
+

Aktive fund@(_instance is null ? "" : $" · {_instance.InstanceKey}")

+ @if (_instance is not null) + { + Vis alle instanser + } +
+
@@ -46,25 +70,55 @@ else {
InstansVersionAgentSeneste data
InstansAktive fundVersionAgentSeneste data
@i.InstanceKey
@i.ServerName
@i.InstanceKey
@i.ServerName
+ @if (_counts.TryGetValue(i.Id, out var counts) && counts.Count > 0) + { + @foreach (var (severity, count) in counts.Where(c => c.Key >= 2).OrderByDescending(c => c.Key)) + { + @(" ") + } + @if (counts.Where(c => c.Key < 2).Sum(c => c.Value) is var minor and > 0) + { + +@minor lav/info + } + } + else + { + Ingen + } + @i.Release
@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel · @i.Edition
@i.AgentVersion @(i.LastSubmissionAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) UTC
- + @foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt)) { - + + + + + + + + }
SagTitelStatusOprettet
SagTitelKildeStatusOprettetLukket
@c.Reference@c.Title@(c.IsOpen ? "Åben" : "Lukket")@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
@c.Reference@c.Title@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")@(c.IsOpen ? "Åben" : "Lukket")@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)@c.ClosedAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
} +

Ny sag

+
+ + + + + +
@code { [Parameter] public int Id { get; set; } + + /// Show only this instance's findings. An id of another customer's instance is ignored (all are shown). + [SupplyParameterFromQuery(Name = "instans")] public int? Instans { get; set; } + private Customer? _customer; + private Instance? _instance; private IReadOnlyList _findings = []; + private IReadOnlyDictionary> _counts = new Dictionary>(); + private string _nextReference = string.Empty; protected override async Task OnInitializedAsync() { _customer = await Queries.CustomerAsync(Id); - _findings = _customer is null ? [] : await Queries.FindingsAsync(Id); + if (_customer is null) + { + return; + } + + _instance = _customer.Instances.FirstOrDefault(i => i.Id == Instans); + _findings = await Queries.FindingsAsync(Id, instanceId: _instance?.Id); + _counts = await Queries.ActiveFindingCountsAsync(Id); + _nextReference = await Queries.NextCaseReferenceAsync(Id); } } diff --git a/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor b/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor index 535d31f..bc8611d 100644 --- a/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor +++ b/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor @@ -50,7 +50,7 @@ }
- + @@ -60,9 +60,14 @@ @code { [Parameter] public long Id { get; set; } private HubFinding? _finding; + private string _nextReference = string.Empty; private IReadOnlyList Evidence => System.Text.Json.JsonSerializer.Deserialize>(_finding!.EvidenceJson, System.Text.Json.JsonSerializerOptions.Web) ?? []; - protected override async Task OnInitializedAsync() => _finding = await Queries.FindingAsync(Id); + protected override async Task OnInitializedAsync() + { + _finding = await Queries.FindingAsync(Id); + _nextReference = _finding is null ? string.Empty : await Queries.NextCaseReferenceAsync(_finding.Instance!.CustomerId); + } } diff --git a/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor b/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor new file mode 100644 index 0000000..18f4d6f --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor @@ -0,0 +1,123 @@ +@page "/portal/rapporter/support" +@attribute [Authorize] +@using SQLInsight.Hub.Support +@inject PortalQueries Queries + +Supportrapport · SQLInsight Hub +@if (_report is null) +{ + return; +} + +

Supportrapport

+

+ Sager oprettet og lukket i perioden, åbne sager nu og hvor hurtigt sagerne blev løst. + Løsningstid er tiden fra oprettelse til lukning for sager lukket i perioden. Tider er UTC. +

+ +
+ + + + + Hent CSV +
+ +
+
@_report.Total.Createdoprettet
+
@_report.Total.Closedlukket
+
@_report.Total.OpenNowåbne nu
+
@SupportReport.Duration(_report.Total.MedianResolution)median løsningstid
+
@SupportReport.Duration(_report.Total.AverageResolution)gennemsnitlig løsningstid
+
+ +
+

Pr. kunde

+ @if (_report.Customers.Count == 0) + { +

Ingen sager i perioden.

+ } + else + { + + + + + + + + + @foreach (var c in _report.Customers.Append(_report.Total)) + { + + + + + + + + + + + } + +
KundeOprettetLukketÅbne nuGennemsnitMedianÆldste åbneOprettet af kunden
@if (c.CustomerId is { } id) { @c.Customer } else { @c.Customer }@c.Created@c.Closed@c.OpenNow@SupportReport.Duration(c.AverageResolution)@SupportReport.Duration(c.MedianResolution)@SupportReport.Duration(c.OldestOpenAge)@(c.CustomerCreatedShare is { } share ? share.ToString("P0", Danish) : "–")
+ } +
+ +
+

Sager i perioden

+ @if (_report.Cases.Count == 0) + { +

Ingen.

+ } + else + { + + + + @foreach (var c in _report.Cases) + { + + + + + + + + + + } + +
SagKundeTitelKildeOprettetLukketLøsningstid
@c.Reference@c.Customer@c.Title@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")@c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)@c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))
+ } +
+ +@code { + private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK"); + + [SupplyParameterFromQuery] public DateTime? Fra { get; set; } + [SupplyParameterFromQuery] public DateTime? Til { get; set; } + [SupplyParameterFromQuery] public int? Kunde { get; set; } + + private SupportReportResult? _report; + private IReadOnlyList<(int Id, string Name)> _customers = []; + + private string CsvUrl => + $"/portal/actions/reports/support.csv?fra={_report!.From:yyyy-MM-dd}&til={_report.To.AddDays(-1):yyyy-MM-dd}{(Kunde is { } k ? $"&kunde={k}" : "")}"; + + protected override async Task OnInitializedAsync() + { + var (from, to) = SupportReportPeriod.Resolve(Fra, Til, Queries.Now); + _report = await Queries.SupportReportAsync(from, to, Kunde); + _customers = await Queries.CustomerNamesAsync(); + } +} diff --git a/src/SQLInsight.Hub/Portal/PortalEndpoints.cs b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs index 5848872..506a4b0 100644 --- a/src/SQLInsight.Hub/Portal/PortalEndpoints.cs +++ b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs @@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SQLInsight.Contracts; using SQLInsight.Hub.Data; +using SQLInsight.Hub.Support; namespace SQLInsight.Hub.Portal; @@ -25,6 +27,8 @@ public static class PortalEndpoints var portal = app.MapGroup("/portal/actions").RequireAuthorization(); portal.MapPost("/findings/{id:long}/case", LinkCaseAsync); + portal.MapPost("/customers/{id:int}/cases", CreateCaseAsync); + portal.MapGet("/reports/support.csv", SupportReportCsvAsync); portal.MapPost("/findings/{id:long}/unlink", async (long id, HubDbContext db, CancellationToken ct) => { var finding = await db.Findings.FindAsync([id], ct); @@ -101,21 +105,56 @@ public static class PortalEndpoints finding.SupportCaseId = supportCase.Id; } - else if (!string.IsNullOrWhiteSpace(reference)) + else { - var supportCase = new SupportCase + // An empty case number gets the customer's next SUP-number. + var (supportCase, error) = await SupportCaseStore.AddAsync(db, new SupportCase { CustomerId = finding.Instance!.CustomerId, - Reference = reference.Trim(), + Reference = reference ?? string.Empty, Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(), CreatedAt = time.GetUtcNow().UtcDateTime, - }; - db.SupportCases.Add(supportCase); - await db.SaveChangesAsync(ct); + }, ct); + if (supportCase is null) + { + return Results.BadRequest(error); + } + finding.SupportCaseId = supportCase.Id; } await db.SaveChangesAsync(ct); return Results.Redirect($"/portal/fund/{id}"); } + + private static async Task CreateCaseAsync(int id, [FromForm] string? reference, [FromForm] string title, [FromForm] string? description, + HubDbContext db, TimeProvider time, CancellationToken ct) + { + if (!await db.Customers.AnyAsync(c => c.Id == id, ct)) + { + return Results.NotFound(); + } + + if (string.IsNullOrWhiteSpace(title) || title.Length > SupportCaseRequest.MaxTitle) + { + return Results.BadRequest($"Titel skal udfyldes (højst {SupportCaseRequest.MaxTitle} tegn)."); + } + + var (_, error) = await SupportCaseStore.AddAsync(db, new SupportCase + { + CustomerId = id, + Reference = reference ?? string.Empty, + Title = title.Trim(), + Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim()[..Math.Min(description.Trim().Length, SupportCaseRequest.MaxDescription)], + CreatedAt = time.GetUtcNow().UtcDateTime, + }, ct); + return error is null ? Results.Redirect($"/portal/kunder/{id}") : Results.BadRequest(error); + } + + private static async Task SupportReportCsvAsync(DateTime? fra, DateTime? til, int? kunde, PortalQueries queries, CancellationToken ct) + { + var (from, to) = SupportReportPeriod.Resolve(fra, til, queries.Now); + var report = await queries.SupportReportAsync(from, to, kunde, ct); + return Results.File(SupportReport.Csv(report), "text/csv; charset=utf-8", $"supportsager-{from:yyyy-MM-dd}-{to.AddDays(-1):yyyy-MM-dd}.csv"); + } } diff --git a/src/SQLInsight.Hub/Portal/PortalQueries.cs b/src/SQLInsight.Hub/Portal/PortalQueries.cs index 38bfb61..eb9e9ea 100644 --- a/src/SQLInsight.Hub/Portal/PortalQueries.cs +++ b/src/SQLInsight.Hub/Portal/PortalQueries.cs @@ -63,9 +63,10 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time) return db.Instances.CountAsync(i => i.LastSubmissionAt < limit, ct); } - public async Task> FindingsAsync(int? customerId = null, int minSeverity = 0, CancellationToken ct = default) => + public async Task> FindingsAsync(int? customerId = null, int minSeverity = 0, int? instanceId = null, CancellationToken ct = default) => await db.Findings .Where(f => f.ResolvedAt == null && f.Severity >= minSeverity && (customerId == null || f.Instance!.CustomerId == customerId)) + .Where(f => instanceId == null || f.InstanceId == instanceId) .OrderByDescending(f => f.Severity).ThenBy(f => f.FirstSeenAt) .Select(f => new FindingRow(f.Id, f.Instance!.Customer!.Name, f.Instance.CustomerId, f.Instance.InstanceKey, f.RuleId, f.Subject, f.Severity, f.Title, f.FirstSeenAt, f.SupportCase == null ? null : f.SupportCase.Reference)) @@ -79,9 +80,43 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time) db.Findings.Include(f => f.Instance!).ThenInclude(i => i.Customer!).ThenInclude(c => c.SupportCases).Include(f => f.SupportCase) .AsSplitQuery().SingleOrDefaultAsync(f => f.Id == id, ct); + /// Open cases, newest first: a customer's new case is what support should see first. public async Task> OpenCasesAsync(CancellationToken ct = default) => await db.SupportCases.Include(c => c.Customer).Include(c => c.Findings).Where(c => c.IsOpen) - .OrderBy(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false); + .OrderByDescending(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false); + + /// Active findings per instance of a customer and severity: instance id → severity → count. + public async Task>> ActiveFindingCountsAsync(int customerId, CancellationToken ct = default) + { + var rows = await db.Findings + .Where(f => f.ResolvedAt == null && f.Instance!.CustomerId == customerId) + .GroupBy(f => new { f.InstanceId, f.Severity }) + .Select(g => new { g.Key.InstanceId, g.Key.Severity, Count = g.Count() }) + .ToListAsync(ct).ConfigureAwait(false); + return rows.GroupBy(r => r.InstanceId) + .ToDictionary(g => g.Key, g => (IReadOnlyDictionary)g.ToDictionary(r => r.Severity, r => r.Count)); + } + + public Task NextCaseReferenceAsync(int customerId, CancellationToken ct = default) => + Support.SupportCaseStore.NextReferenceAsync(db, customerId, ct); + + public async Task> CustomerNamesAsync(CancellationToken ct = default) => + (await db.Customers.OrderBy(c => c.Name).Select(c => new { c.Id, c.Name }).ToListAsync(ct).ConfigureAwait(false)) + .Select(c => (c.Id, c.Name)).ToList(); + + /// The support report for [from, to) UTC, for all customers or one. + public async Task SupportReportAsync(DateTime from, DateTime to, int? customerId, CancellationToken ct = default) + { + var cases = await db.SupportCases + .Where(c => customerId == null || c.CustomerId == customerId) + .Where(c => c.IsOpen || (c.CreatedAt >= from && c.CreatedAt < to) || (c.ClosedAt >= from && c.ClosedAt < to)) + .Select(c => new { c.Id, c.CustomerId, Customer = c.Customer!.Name, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen, c.ClosedAt }) + .ToListAsync(ct).ConfigureAwait(false); + // A closed case without a closing time (closed before it was recorded) counts as closed when it was created. + return Support.SupportReport.Build( + cases.Select(c => new Support.SupportReportCase(c.Id, c.CustomerId, c.Customer, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen ? null : c.ClosedAt ?? c.CreatedAt)), + from, to, Now); + } public async Task> LicensesAsync(CancellationToken ct = default) => await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false); diff --git a/src/SQLInsight.Hub/Portal/Shared/CaseDetails.razor b/src/SQLInsight.Hub/Portal/Shared/CaseDetails.razor new file mode 100644 index 0000000..4ed2197 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Shared/CaseDetails.razor @@ -0,0 +1,19 @@ +@* Contact and description of a case, folded away: shown under the title in case lists. *@ +@if (Case.ContactName is not null || Case.Description is not null) +{ +
+ @(Case.ContactName is { } name ? $"{name}{(Case.RequestedBy is { } user ? $" ({user})" : "")}" : "Beskrivelse") + @if (Case.ContactEmail is { } email) + { +
@email@(Case.ContactPhone is { } phone ? $" · {phone}" : "")
+ } + @if (Case.Description is { } description) + { +

@description

+ } +
+} + +@code { + [Parameter, EditorRequired] public SupportCase Case { get; set; } = null!; +} diff --git a/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor b/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor index 6abb14b..f9b061a 100644 --- a/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor +++ b/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor @@ -5,14 +5,14 @@ else { - @if (ShowCustomer) { } + @if (ShowCustomer) { }@if (ShowInstance) { } @foreach (var f in Rows) { @if (ShowCustomer) { } - + @if (ShowInstance) { } @@ -26,4 +26,5 @@ else @code { [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = []; [Parameter] public bool ShowCustomer { get; set; } = true; + [Parameter] public bool ShowInstance { get; set; } = true; } diff --git a/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor index ac61f8e..706208a 100644 --- a/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor +++ b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor @@ -9,6 +9,7 @@ Alle fund Opgraderinger Supportsager + Supportrapport Licenser
diff --git a/src/SQLInsight.Hub/Program.cs b/src/SQLInsight.Hub/Program.cs index 5081ee9..f443aea 100644 --- a/src/SQLInsight.Hub/Program.cs +++ b/src/SQLInsight.Hub/Program.cs @@ -66,6 +66,7 @@ public static class HubProgram builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddRazorComponents(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All)); @@ -126,6 +127,7 @@ public static class HubProgram app.MapGet("/", () => Results.Redirect("/portal")); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); FindingsEndpoint.Map(app); + CasesEndpoint.Map(app); SQLInsight.Hub.Insights.InsightsEndpoint.Map(app); PortalEndpoints.Map(app); app.MapRazorComponents(); diff --git a/src/SQLInsight.Hub/SQLInsight.Hub.csproj b/src/SQLInsight.Hub/SQLInsight.Hub.csproj index 2e041a5..71cb216 100644 --- a/src/SQLInsight.Hub/SQLInsight.Hub.csproj +++ b/src/SQLInsight.Hub/SQLInsight.Hub.csproj @@ -14,5 +14,6 @@ + diff --git a/src/SQLInsight.Hub/Support/CaseNumbers.cs b/src/SQLInsight.Hub/Support/CaseNumbers.cs new file mode 100644 index 0000000..bf3bd9d --- /dev/null +++ b/src/SQLInsight.Hub/Support/CaseNumbers.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace SQLInsight.Hub.Support; + +/// Case numbers SUP-000001, SUP-000002 … running per customer. +public static partial class CaseNumbers +{ + public const string Prefix = "SUP-"; + + /// + /// The number after the highest SUP-<digits> among . Other references (typed by hand, + /// e.g. from an older system) are ignored, so they never block or reset the sequence. + /// + public static string Next(IEnumerable existing) + { + ArgumentNullException.ThrowIfNull(existing); + long highest = 0; + foreach (var reference in existing) + { + var match = Number().Match(reference ?? string.Empty); + if (match.Success && long.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var n) && n > highest) + { + highest = n; + } + } + + return Format(highest + 1); + } + + public static string Format(long number) => Prefix + number.ToString("D6", CultureInfo.InvariantCulture); + + [GeneratedRegex(@"^\s*SUP-(\d{1,18})\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex Number(); +} diff --git a/src/SQLInsight.Hub/Support/SupportCaseStore.cs b/src/SQLInsight.Hub/Support/SupportCaseStore.cs new file mode 100644 index 0000000..b5f4cbd --- /dev/null +++ b/src/SQLInsight.Hub/Support/SupportCaseStore.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore; +using SQLInsight.Contracts; +using SQLInsight.Hub.Data; + +namespace SQLInsight.Hub.Support; + +/// Creates support cases with a case number that is unique per customer. +public static class SupportCaseStore +{ + private const int Attempts = 5; + + /// The number the next case for the customer gets unless another is typed: SUP-000001, SUP-000002 … + public static async Task 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); + } + + /// + /// Adds and saves it. An empty 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. + /// + /// The saved case, or the reason it was not saved. + 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)); +} diff --git a/src/SQLInsight.Hub/Support/SupportNotifier.cs b/src/SQLInsight.Hub/Support/SupportNotifier.cs new file mode 100644 index 0000000..4065b3a --- /dev/null +++ b/src/SQLInsight.Hub/Support/SupportNotifier.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Text; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; +using SQLInsight.Hub.Data; + +namespace SQLInsight.Hub.Support; + +/// SQLInsight:Hub:Support: what happens when a customer creates a support case. +public sealed class SupportOptions +{ + public SupportNotifyOptions Notify { get; set; } = new(); +} + +/// E-mail to SEOSoft's support when a customer creates a case in SQLInsight Web. +public sealed class SupportNotifyOptions +{ + public bool Enabled { get; set; } + public string? SmtpHost { get; set; } + public int SmtpPort { get; set; } = 587; + + /// STARTTLS on the port (Microsoft 365: smtp.office365.com:587). False: TLS from the start (port 465). + public bool StartTls { get; set; } = true; + + public string? UserName { get; set; } + public string? Password { get; set; } + public string? From { get; set; } + public IList To { get; } = []; + + /// The portal's public address, for a link to the case in the mail (e.g. https://hub.sqlinsight.seosoft.dk/). + public Uri? PortalUrl { get; set; } + + public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(SmtpHost) && !string.IsNullOrWhiteSpace(From) && To.Count > 0; +} + +/// +/// Mails support about a new customer case. A mail that cannot be sent is logged and otherwise ignored: the case is +/// saved and visible in the portal either way. +/// +public sealed partial class SupportNotifier(IOptions options, ILogger logger) +{ + public async Task NotifyAsync(SupportCase supportCase, string customer, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(supportCase); + var settings = options.Value.Support.Notify; + if (!settings.IsConfigured) + { + return; + } + + try + { + using var client = new SmtpClient { Timeout = 20_000 }; + await client.ConnectAsync(settings.SmtpHost!, settings.SmtpPort, settings.StartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.SslOnConnect, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(settings.UserName)) + { + // A password can never do OAuth, and Microsoft 365 rejects PLAIN: prefer LOGIN where it is offered. + client.AuthenticationMechanisms.Remove("XOAUTH2"); + if (client.AuthenticationMechanisms.Contains("LOGIN")) + { + client.AuthenticationMechanisms.Remove("PLAIN"); + } + + await client.AuthenticateAsync(settings.UserName, settings.Password ?? string.Empty, cancellationToken).ConfigureAwait(false); + } + + await client.SendAsync(Build(settings, supportCase, customer), cancellationToken).ConfigureAwait(false); + await client.DisconnectAsync(true, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogMailFailed(logger, ex, supportCase.Reference, customer); + } + } + + internal static MimeMessage Build(SupportNotifyOptions settings, SupportCase c, string customer) + { + var message = new MimeMessage(); + message.From.Add(MailboxAddress.Parse(settings.From!)); + foreach (var to in settings.To) + { + message.To.Add(MailboxAddress.Parse(to)); + } + + if (c.ContactEmail is { Length: > 0 } email && MailboxAddress.TryParse(email, out var replyTo)) + { + replyTo.Name = c.ContactName; + message.ReplyTo.Add(replyTo); + } + + message.Subject = $"Ny supportsag {c.Reference} fra {customer}: {c.Title}"; + var lines = new List<(string Label, string? Value)> + { + ("Kunde", customer), + ("Sag", c.Reference), + ("Titel", c.Title), + ("Kontakt", c.ContactName), + ("E-mail", c.ContactEmail), + ("Telefon", c.ContactPhone), + ("Oprettet af", c.RequestedBy), + }; + var text = new StringBuilder(); + var html = new StringBuilder("
AlvorKundeInstansFundRegelSagSiden
AlvorKundeInstansFundRegelSagSiden
@f.Customer@f.Instance@f.Instance@f.Title @f.RuleId @(f.CaseReference ?? "–")
"); + foreach (var (label, value) in lines.Where(l => !string.IsNullOrWhiteSpace(l.Value))) + { + text.Append(label).Append(": ").Append(value).Append('\n'); + html.Append(""); + } + + html.Append("
").Append(WebUtility.HtmlEncode(label)).Append("") + .Append(WebUtility.HtmlEncode(value)).Append("
"); + if (!string.IsNullOrWhiteSpace(c.Description)) + { + text.Append('\n').Append(c.Description).Append('\n'); + html.Append("

").Append(WebUtility.HtmlEncode(c.Description)).Append("

"); + } + + if (settings.PortalUrl is { } portal) + { + var link = new Uri(portal, $"portal/kunder/{c.CustomerId}"); + text.Append('\n').Append(link).Append('\n'); + html.Append("

Åbn kunden i SQLInsight Hub

"); + } + + html.Append("

Sendt af SQLInsight Hub. Svar på mailen går til kunden.

"); + message.Body = new BodyBuilder { TextBody = text.ToString(), HtmlBody = html.ToString() }.ToMessageBody(); + return message; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not mail support about case {Reference} from {Customer}")] + private static partial void LogMailFailed(ILogger logger, Exception exception, string reference, string customer); +} diff --git a/src/SQLInsight.Hub/Support/SupportReport.cs b/src/SQLInsight.Hub/Support/SupportReport.cs new file mode 100644 index 0000000..98c25d6 --- /dev/null +++ b/src/SQLInsight.Hub/Support/SupportReport.cs @@ -0,0 +1,153 @@ +using System.Globalization; +using System.Text; +using SQLInsight.Contracts; + +namespace SQLInsight.Hub.Support; + +/// A support case as the report needs it. +public sealed record SupportReportCase(int Id, int CustomerId, string Customer, string Reference, string Title, string Source, DateTime CreatedAt, DateTime? ClosedAt) +{ + public TimeSpan? ResolutionTime => ClosedAt - CreatedAt; +} + +/// Key figures for one customer (or, for the total row, all customers). +public sealed record SupportReportCustomer( + int? CustomerId, + string Customer, + int Created, + int Closed, + int OpenNow, + TimeSpan? AverageResolution, + TimeSpan? MedianResolution, + TimeSpan? OldestOpenAge, + double? CustomerCreatedShare); + +public sealed record SupportReportResult( + DateTime From, + DateTime To, + IReadOnlyList Customers, + SupportReportCustomer Total, + IReadOnlyList Cases); + +/// The report period from the form: whole days, "til" included. Default: the last 90 days up to today. +public static class SupportReportPeriod +{ + public const int DefaultDays = 90; + + /// [From, To) in UTC. + public static (DateTime From, DateTime To) Resolve(DateTime? from, DateTime? to, DateTime now) + { + var end = (to?.Date ?? now.Date).AddDays(1); + var start = from?.Date ?? end.AddDays(-DefaultDays); + if (start >= end) + { + (start, end) = (end.AddDays(-1), start.AddDays(1)); + } + + return (DateTime.SpecifyKind(start, DateTimeKind.Utc), DateTime.SpecifyKind(end, DateTimeKind.Utc)); + } +} + +/// +/// The support report: how many cases were created and closed in a period, how many are open now, and how fast they +/// were solved. Resolution time is ClosedAt − CreatedAt of the cases closed in the period. +/// +public static class SupportReport +{ + /// Every case that may be relevant: created or closed in the period, or still open. + /// Start of the period (inclusive, UTC). + /// End of the period (exclusive, UTC). + /// Now (UTC), for the age of open cases. + public static SupportReportResult Build(IEnumerable cases, DateTime from, DateTime to, DateTime now) + { + ArgumentNullException.ThrowIfNull(cases); + var all = cases.ToList(); + bool InPeriod(DateTime t) => t >= from && t < to; + + var listed = all + .Where(c => InPeriod(c.CreatedAt) || c.ClosedAt is { } closed && InPeriod(closed)) + .OrderByDescending(c => c.CreatedAt) + .ToList(); + + var customers = all + .GroupBy(c => (c.CustomerId, c.Customer)) + .Select(g => Figures(g.Key.CustomerId, g.Key.Customer, g.ToList(), InPeriod, now)) + .Where(f => f.Created > 0 || f.Closed > 0 || f.OpenNow > 0) + .OrderByDescending(f => f.OpenNow).ThenByDescending(f => f.Created).ThenBy(f => f.Customer, StringComparer.CurrentCulture) + .ToList(); + + return new SupportReportResult(from, to, customers, Figures(null, "I alt", all, InPeriod, now), listed); + } + + private static SupportReportCustomer Figures(int? customerId, string name, IReadOnlyList cases, Func inPeriod, DateTime now) + { + var created = cases.Where(c => inPeriod(c.CreatedAt)).ToList(); + var resolutions = cases.Where(c => c.ClosedAt is { } closed && inPeriod(closed)).Select(c => c.ResolutionTime!.Value).ToList(); + var open = cases.Where(c => c.ClosedAt is null).ToList(); + return new SupportReportCustomer( + customerId, + name, + created.Count, + resolutions.Count, + open.Count, + resolutions.Count == 0 ? null : TimeSpan.FromTicks((long)resolutions.Average(r => r.Ticks)), + Median(resolutions), + open.Count == 0 ? null : now - open.Min(c => c.CreatedAt), + created.Count == 0 ? null : (double)created.Count(c => c.Source == SupportCaseSources.Customer) / created.Count); + } + + public static TimeSpan? Median(IReadOnlyCollection values) + { + ArgumentNullException.ThrowIfNull(values); + if (values.Count == 0) + { + return null; + } + + var sorted = values.OrderBy(v => v).ToList(); + var middle = sorted.Count / 2; + return sorted.Count % 2 == 1 ? sorted[middle] : TimeSpan.FromTicks((sorted[middle - 1].Ticks + sorted[middle].Ticks) / 2); + } + + private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK"); + + /// Readable duration: "45 min", "6,5 t", "3,2 dage". + public static string Duration(TimeSpan? value) => value switch + { + null => "–", + { TotalMinutes: < 60 } v => $"{Math.Max(0, (int)Math.Round(v.TotalMinutes))} min", + { TotalHours: < 48 } v => $"{v.TotalHours.ToString("0.#", Danish)} t", + { } v => $"{v.TotalDays.ToString("0.#", Danish)} dage", + }; + + /// + /// The cases as CSV for Excel in Danish: UTF-8 with BOM and semicolons. Times in UTC; resolution time in hours. + /// + public static byte[] Csv(SupportReportResult report) + { + ArgumentNullException.ThrowIfNull(report); + var csv = new StringBuilder(); + csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Løsningstid (timer)\r\n"); + foreach (var c in report.Cases) + { + csv.AppendJoin(';', + Field(c.Reference), + Field(c.Customer), + Field(c.Title), + Field(c.Source == SupportCaseSources.Customer ? "Kunde" : "SEOSoft"), + c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), + c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty, + c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty); + csv.Append("\r\n"); + } + + return [.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes(csv.ToString())]; + } + + /// Quoted when needed; a leading =, +, - or @ is defused so Excel does not run it as a formula. + private static string Field(string value) + { + var text = value.Length > 0 && "=+-@".Contains(value[0], StringComparison.Ordinal) ? "'" + value : value; + return text.IndexOfAny([';', '"', '\r', '\n']) >= 0 ? "\"" + text.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"" : text; + } +} diff --git a/src/SQLInsight.Hub/wwwroot/app.css b/src/SQLInsight.Hub/wwwroot/app.css index a80bbe1..3748984 100644 --- a/src/SQLInsight.Hub/wwwroot/app.css +++ b/src/SQLInsight.Hub/wwwroot/app.css @@ -167,3 +167,13 @@ input, select { font: inherit; padding: .5rem .65rem; border: 1px solid var(--bo .nowrap { white-space: nowrap; } td .inline-form { flex-wrap: nowrap; margin: 0; } td .inline-form input { width: 8rem; min-width: 0; } + +/* Support cases: contact and description folded under the title. */ +.case-details { margin-top: .25rem; } +.case-details summary { cursor: pointer; font-size: .82rem; } +.case-description { white-space: pre-wrap; margin: .35rem 0 0; font-size: .88rem; } +.report-filter label { display: inline-flex; flex-direction: column; font-size: .8rem; color: var(--text-2); gap: .2rem; } + +/* Customer page: the instance the findings are filtered to. */ +tr.selected td { background: var(--accent-soft); } +tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--accent); } diff --git a/tests/SQLInsight.IntegrationTests/HubPortalSupportTests.cs b/tests/SQLInsight.IntegrationTests/HubPortalSupportTests.cs new file mode 100644 index 0000000..677668b --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/HubPortalSupportTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SQLInsight.Hub.Data; + +namespace SQLInsight.IntegrationTests; + +/// Support cases in the portal: suggested case numbers and the support report. +public partial class HubPortalTests +{ + private async Task CustomerIdAsync(string customer) + { + await using var scope = hub.Factory.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync(); + } + + [Fact] + public async Task New_case_suggests_the_next_number_and_refuses_a_taken_one() + { + var customer = $"Forslag {Guid.NewGuid():N}"[..22]; + var finding = await SeedAsync(customer, hub.License(customer)); + var id = await CustomerIdAsync(customer); + var client = await SignedInAsync(); + + Assert.Contains("value=\"SUP-000001\"", await client.GetStringAsync($"/portal/fund/{finding}"), StringComparison.Ordinal); + var created = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Telefonopkald"), ("description", "Ringede om backup")); + Assert.Equal($"/portal/kunder/{id}", created.Headers.Location?.OriginalString); + var page = await client.GetStringAsync($"/portal/kunder/{id}"); + Assert.Contains("SUP-000001", page, StringComparison.Ordinal); + Assert.Contains("value=\"SUP-000002\"", page, StringComparison.Ordinal); + + var duplicate = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", "SUP-000001"), ("title", "Igen")); + Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); + } + + [Fact] + public async Task Support_report_shows_cases_and_exports_csv() + { + var customer = $"Rapport {Guid.NewGuid():N}"[..22]; + await SeedAsync(customer, hub.License(customer)); + var id = await CustomerIdAsync(customer); + var client = await SignedInAsync(); + await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Til rapporten")); + + var response = await client.GetAsync($"/portal/rapporter/support?kunde={id}"); + var page = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, page); + var csv = await client.GetAsync($"/portal/actions/reports/support.csv?kunde={id}"); + var bytes = await csv.Content.ReadAsByteArrayAsync(); + + Assert.Contains("Til rapporten", page, StringComparison.Ordinal); + Assert.Contains("median løsningstid", page, StringComparison.Ordinal); + Assert.Equal("text/csv", csv.Content.Headers.ContentType?.MediaType); + Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]); + Assert.Contains("SUP-000001;" + customer + ";Til rapporten;SEOSoft;", System.Text.Encoding.UTF8.GetString(bytes), StringComparison.Ordinal); + } + + [Fact] + public async Task Customer_page_filters_findings_to_one_instance() + { + var customer = $"Instans {Guid.NewGuid():N}"[..22]; + var license = hub.License(customer); + await SeedAsync(customer, license); + using (var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings") + { + Content = System.Net.Http.Json.JsonContent.Create(HubApiTests.Submission("sql02", DateTimeOffset.UtcNow, ("SQLI-CFG-001", "", "High"))), + }) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SQLInsight-License", license); + (await hub.Factory.CreateClient().SendAsync(request)).EnsureSuccessStatusCode(); + } + + var other = $"Fremmed {Guid.NewGuid():N}"[..22]; + await SeedAsync(other, hub.License(other)); + int id, sql02, foreign; + await using (var scope = hub.Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + id = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync(); + sql02 = await db.Instances.Where(i => i.CustomerId == id && i.InstanceKey == "sql02").Select(i => i.Id).SingleAsync(); + foreign = await db.Instances.Where(i => i.Customer!.Name == other).Select(i => i.Id).SingleAsync(); + } + + var client = await SignedInAsync(); + var all = await client.GetStringAsync($"/portal/kunder/{id}"); + var filtered = await client.GetStringAsync($"/portal/kunder/{id}?instans={sql02}"); + var ignored = await client.GetStringAsync($"/portal/kunder/{id}?instans={foreign}"); + + Assert.Contains("SQLI-DB-007", all, StringComparison.Ordinal); + Assert.Contains("SQLI-CFG-001", all, StringComparison.Ordinal); + Assert.Contains($"?instans={sql02}", all, StringComparison.Ordinal); + Assert.Contains("Aktive fund · sql02", filtered, StringComparison.Ordinal); + Assert.Contains("Vis alle instanser", filtered, StringComparison.Ordinal); + Assert.Contains("SQLI-CFG-001", filtered, StringComparison.Ordinal); + Assert.DoesNotContain("SQLI-DB-007", filtered, StringComparison.Ordinal); + Assert.Contains("SQLI-DB-007", ignored, StringComparison.Ordinal); + Assert.Contains("SQLI-CFG-001", ignored, StringComparison.Ordinal); + Assert.DoesNotContain("Vis alle instanser", ignored, StringComparison.Ordinal); + } + + [Fact] + public async Task Support_report_csv_requires_login() + { + var response = await Client(hub.Factory).GetAsync("/portal/actions/reports/support.csv"); + + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/tests/SQLInsight.IntegrationTests/HubSupportApiTests.cs b/tests/SQLInsight.IntegrationTests/HubSupportApiTests.cs new file mode 100644 index 0000000..7f0605e --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/HubSupportApiTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SQLInsight.Contracts; +using SQLInsight.Hub.Data; +using SQLInsight.Licensing; + +namespace SQLInsight.IntegrationTests; + +[Trait("Category", "Integration")] +public class HubSupportApiTests(HubFixture hub) : IClassFixture +{ + private static SupportCaseRequest Request(Guid? id = null, string title = "Backup fejler") => + new(id ?? Guid.NewGuid(), title, "Siden i går", "Anna Andersen", "anna@kunde.dk", "12345678", @"KUNDE\anna"); + + private async Task PostAsync(string license, SupportCaseRequest request) + { + using var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/cases") { Content = JsonContent.Create(request) }; + message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license); + return await hub.Factory.CreateClient().SendAsync(message); + } + + private async Task CreateAsync(string license, SupportCaseRequest request) + { + var response = await PostAsync(license, request); + Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync()); + return (await response.Content.ReadFromJsonAsync(JsonSerializerOptions.Web))!; + } + + private static string Name(string prefix) => $"{prefix} {Guid.NewGuid():N}"[..24]; + + [Fact] + public async Task Customer_cases_are_numbered_SUP_000001_then_SUP_000002() + { + var customer = Name("Nummer"); + var license = hub.License(customer); + + var first = await CreateAsync(license, Request()); + var second = await CreateAsync(license, Request()); + + Assert.Equal("SUP-000001", first.Reference); + Assert.Equal("SUP-000002", second.Reference); + Assert.Equal(SupportCaseSources.Customer, first.Source); + Assert.True(first.IsOpen); + + await using var scope = hub.Factory.Services.CreateAsyncScope(); + var stored = await scope.ServiceProvider.GetRequiredService().SupportCases.SingleAsync(c => c.Customer!.Name == customer && c.Reference == "SUP-000001"); + Assert.Equal(("Anna Andersen", "anna@kunde.dk", @"KUNDE\anna"), (stored.ContactName, stored.ContactEmail, stored.RequestedBy)); + } + + [Fact] + public async Task A_retried_request_returns_the_same_case() + { + var customer = Name("Igen"); + var license = hub.License(customer); + var request = Request(); + + var first = await CreateAsync(license, request); + var again = await CreateAsync(license, request); + + Assert.Equal(first.Reference, again.Reference); + await using var scope = hub.Factory.Services.CreateAsyncScope(); + Assert.Equal(1, await scope.ServiceProvider.GetRequiredService().SupportCases.CountAsync(c => c.Customer!.Name == customer)); + } + + [Fact] + public async Task Each_customer_has_its_own_sequence() + { + await CreateAsync(hub.License(Name("Første")), Request()); + + var other = await CreateAsync(hub.License(Name("Anden")), Request()); + + Assert.Equal("SUP-000001", other.Reference); + } + + [Fact] + public async Task Community_license_cannot_create_cases() + { + var response = await PostAsync(hub.License(Name("Gratis"), LicensePlan.Community), Request()); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Invalid_request_is_rejected() + { + var response = await PostAsync(hub.License(Name("Ugyldig")), Request() with { ContactEmail = "ingen-snabel-a" }); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } + + [Fact] + public async Task Customer_sees_its_own_cases_including_those_support_created() + { + var customer = Name("Liste"); + var license = hub.License(customer); + await CreateAsync(license, Request(title: "Fra kunden")); + await CreateAsync(hub.License(Name("Fremmed")), Request(title: "Andens sag")); + await using (var scope = hub.Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var customerId = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync(); + await SQLInsight.Hub.Support.SupportCaseStore.AddAsync(db, new SupportCase + { + CustomerId = customerId, Reference = string.Empty, Title = "Fra SEOSoft", CreatedAt = DateTime.UtcNow, IsOpen = false, ClosedAt = DateTime.UtcNow, + }); + } + + using var message = new HttpRequestMessage(HttpMethod.Get, "/api/v1/cases"); + message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license); + var cases = (await (await hub.Factory.CreateClient().SendAsync(message)).Content.ReadFromJsonAsync>(JsonSerializerOptions.Web))!; + + Assert.Equal(["Fra SEOSoft", "Fra kunden"], cases.Select(c => c.Title)); + Assert.Equal("SUP-000002", cases[0].Reference); + Assert.False(cases[0].IsOpen); + } + + [Fact] + public async Task Case_created_from_a_finding_is_linked_to_it() + { + var customer = Name("Fund"); + var license = hub.License(customer); + using (var submit = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings") + { + Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-DB-007", "database: Sales", "Critical"))), + }) + { + submit.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license); + (await hub.Factory.CreateClient().SendAsync(submit)).EnsureSuccessStatusCode(); + } + + var created = await CreateAsync(license, Request() with { InstanceName = "sql01", RuleId = "SQLI-DB-007", Subject = "database: Sales" }); + + await using var scope = hub.Factory.Services.CreateAsyncScope(); + var finding = await scope.ServiceProvider.GetRequiredService().Findings.Include(f => f.SupportCase) + .SingleAsync(f => f.Instance!.Customer!.Name == customer); + Assert.Equal(created.Reference, finding.SupportCase?.Reference); + } +} diff --git a/tests/SQLInsight.IntegrationTests/HubSupportLogicTests.cs b/tests/SQLInsight.IntegrationTests/HubSupportLogicTests.cs new file mode 100644 index 0000000..519e075 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/HubSupportLogicTests.cs @@ -0,0 +1,99 @@ +using SQLInsight.Contracts; +using SQLInsight.Hub.Support; + +namespace SQLInsight.IntegrationTests; + +/// Pure logic behind support cases: no database, so these run with the unit tests. +public class HubSupportLogicTests +{ + [Fact] + public void First_case_number_is_SUP_000001() => Assert.Equal("SUP-000001", CaseNumbers.Next([])); + + [Fact] + public void Next_number_follows_the_highest_SUP_number() => + Assert.Equal("SUP-000013", CaseNumbers.Next(["SUP-000002", "sup-000012", "SUP-000009"])); + + [Fact] + public void Hand_typed_references_do_not_affect_the_sequence() => + Assert.Equal("SUP-000004", CaseNumbers.Next(["SUP-000003", "INC-99999", "SUP-12A", "Sag fra telefon"])); + + [Fact] + public void Old_short_numbers_continue_in_six_digits() => Assert.Equal("SUP-001235", CaseNumbers.Next(["SUP-1234"])); + + private static readonly DateTime T0 = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc); + + private static SupportReportCase Case(int id, int customer, double createdDays, double? closedAfterHours, string source = SupportCaseSources.Staff) => + new(id, customer, $"Kunde {customer}", $"SUP-{id:D6}", "Titel", source, T0.AddDays(createdDays), closedAfterHours is { } h ? T0.AddDays(createdDays).AddHours(h) : null); + + [Fact] + public void Report_counts_created_closed_and_open_and_computes_average_and_median() + { + var cases = new[] + { + Case(1, 1, 1, 2, SupportCaseSources.Customer), + Case(2, 1, 2, 4), + Case(3, 1, 3, 30), + Case(4, 1, 4, null, SupportCaseSources.Customer), + Case(5, 2, -40, 1), // created and closed before the period: not counted + Case(6, 2, -40, null), // created before, still open: open now + }; + + var report = SupportReport.Build(cases, T0, T0.AddDays(30), T0.AddDays(10)); + var first = report.Customers.Single(c => c.CustomerId == 1); + var second = report.Customers.Single(c => c.CustomerId == 2); + + Assert.Equal((4, 3, 1), (first.Created, first.Closed, first.OpenNow)); + Assert.Equal(TimeSpan.FromHours(12), first.AverageResolution); + Assert.Equal(TimeSpan.FromHours(4), first.MedianResolution); + Assert.Equal(TimeSpan.FromDays(6), first.OldestOpenAge); + Assert.Equal(0.5, first.CustomerCreatedShare); + Assert.Equal((0, 0, 1), (second.Created, second.Closed, second.OpenNow)); + Assert.Equal(TimeSpan.FromDays(50), second.OldestOpenAge); + Assert.Equal((4, 3, 2), (report.Total.Created, report.Total.Closed, report.Total.OpenNow)); + Assert.Equal([4, 3, 2, 1], report.Cases.Select(c => c.Id)); + } + + [Fact] + public void Median_of_an_even_count_is_the_mean_of_the_middle_two() => + Assert.Equal(TimeSpan.FromHours(3), SupportReport.Median([TimeSpan.FromHours(1), TimeSpan.FromHours(2), TimeSpan.FromHours(4), TimeSpan.FromHours(9)])); + + [Theory] + [InlineData(30, "30 min")] + [InlineData(90, "1,5 t")] + [InlineData(4320, "3 dage")] + public void Durations_are_readable(double minutes, string expected) => Assert.Equal(expected, SupportReport.Duration(TimeSpan.FromMinutes(minutes))); + + [Fact] + public void Default_period_is_the_last_90_days_including_today() + { + var (from, to) = SupportReportPeriod.Resolve(null, null, new DateTime(2026, 10, 1, 14, 0, 0, DateTimeKind.Utc)); + + Assert.Equal(new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc), to); + Assert.Equal(to.AddDays(-90), from); + } + + [Fact] + public void Csv_has_bom_semicolons_and_defuses_formulas() + { + var report = SupportReport.Build([Case(1, 1, 1, 6) with { Title = "=HYPERLINK(\"x\"); ond" }], T0, T0.AddDays(30), T0.AddDays(10)); + + var bytes = SupportReport.Csv(report); + var text = System.Text.Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3); + + Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]); + Assert.StartsWith("Sag;Kunde;Titel;Kilde;", text, StringComparison.Ordinal); + Assert.Contains("\"'=HYPERLINK(\"\"x\"\"); ond\"", text, StringComparison.Ordinal); + Assert.Contains(";6,0\r\n", text, StringComparison.Ordinal); + } + + [Fact] + public void Request_validation_rejects_missing_contact_and_long_titles() + { + var valid = new SupportCaseRequest(Guid.NewGuid(), "Langsom server", null, "Anna", "anna@kunde.dk", null, null); + + Assert.Null(valid.Validate()); + Assert.NotNull((valid with { ContactEmail = "anna" }).Validate()); + Assert.NotNull((valid with { Title = new string('x', 201) }).Validate()); + Assert.NotNull((valid with { ClientRequestId = Guid.Empty }).Validate()); + } +}