diff --git a/docs/hub.md b/docs/hub.md index c26ffbf..069f664 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -113,3 +113,7 @@ dotnet SQLInsight.Hub.dll delete-customer "Kunde A/S" --confirm ``` Alle kundens instanser, fund, regelstatus, modtagelser og supportsager slettes. Licenserne spærres, så agenterne ikke kan sende igen, og kunden omdøbes til "Slettet kunde #id". Se også [databehandling.md](databehandling.md). + +### Kunden lukker en sag + +`POST /api/v1/cases/{reference}/close` (licens som Authorization, kræver support i licensen) lukker en af licensens egne sager; andres sager giver 404. Body `{ comment?, requestedBy? }`. Sagen får `ClosedBy = "Kunde: "` og kommentaren; lukning i portalen giver `ClosedBy = "SEOSoft: "`. Kaldet er idempotent. Agenten sender lukninger, som kunden har bedt om i SQLInsight Web, og en senere liste fra hubben sætter ikke en ventende lukning tilbage til åben. diff --git a/docs/integration.md b/docs/integration.md index e439565..8733bf2 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -80,6 +80,7 @@ Alt, hvad webbrugerfladen viser, kan hentes som JSON under `/api/v1/` med de sam | `GET /api/v1/instances/{navn}/privileges?days=90` | Rettigheder med udfoldede Windows-grupper, sa, adgangskodepolitik, default trace og ændringer | | `GET /api/v1/privileges?days=90` | Det samme for alle instanser | | `GET /api/v1/instances/{navn}/wait-types/{ventetype}?from=&to=` | Forklaring, udvikling og de sætninger, der ventede | +| `POST /api/v1/support-cases`, `GET /api/v1/support-cases[/{id}]`, `POST /api/v1/support-cases/{id}/close` | Supportsager: se [support.md](support.md) (kræver scope `support:write` for at skrive) | | `GET /metrics` | Prometheus-format | **Følsomme data:** forespørgselstekster, logins, personnavne, værts- og programnavne og tjenestekonti vises som `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er `true`. Det gælder både fund, rapporter i JSON og sidernes data. PDF-rapporterne indeholder de samme data som i webbrugerfladen. diff --git a/docs/support.md b/docs/support.md index 0be2a22..1f6ef76 100644 --- a/docs/support.md +++ b/docs/support.md @@ -23,3 +23,24 @@ Siden **Support** viser alle jeres sager, også dem SEOSoft har oprettet for jer Webbrugerfladens login har kun `db_datareader` på repositoriet. For at kunne oprette en sag har rollen `public` i repository-databasen fået `INSERT` på den ene tabel `insight.SupportCases` (migration `0005_support_cases.sql`). Kun repositoriets egne brugere findes i den database. Hvad der sendes til SEOSoft, står i [databehandling.md](databehandling.md). + +## Supportsager via API'et + +Sager kan oprettes, læses og lukkes fra fx et ITSM-system (ServiceNow, TOPdesk) eller PowerShell. Det kræver det samme som formularen (Professional/Enterprise og deling med SQLInsight Hub), og et token med scope `support:write` (oprettes med `SQLInsight.Web.exe new-api-token itsm --scope read,support:write`). Windows-brugere med adgang til webbrugerfladen må det samme. + +| Kald | Svar | +|---|---| +| `POST /api/v1/support-cases` | Body `{ title, description, contactName, contactEmail, contactPhone?, findingId? }`. Med `findingId` udfyldes titel og beskrivelse med fundets ikke-følsomme oplysninger. Svar 202 med lokalt `id` og status `Pending`; sagsnummeret (SUP-…) kommer, når agenten har talt med hubben. Header `Idempotency-Key` giver samme sag ved gentagelse. 403 (problem+json) uden scope eller licens | +| `GET /api/v1/support-cases?status=&from=&to=&limit=` | Sagerne, nyeste først (sider med `X-Continuation`) | +| `GET /api/v1/support-cases/{id}` | Status (`Pending`, `Open`, `CloseRequested`, `Closed`, `Failed`), sagsnummer, oprettet/lukket, lukket af, fejl | +| `POST /api/v1/support-cases/{id}/close` | Body `{ comment? }`. Status `CloseRequested`, indtil hubben har bekræftet, så `Closed`. Allerede lukket: 200. En sag uden sagsnummer endnu lukkes, så snart den er oprettet | + +Sager kan også lukkes med knappen **Luk sag** på siden Support. SEOSoft ser i portalen, hvem der lukkede sagen (kunde eller SEOSoft) og kommentaren, og supportrapporten har kolonnen "Lukket af". + +```powershell +$h = @{ Authorization = "Bearer $token"; "Idempotency-Key" = "INC0012345" } +$sag = Invoke-RestMethod https://sqlinsight.domæne.local/api/v1/support-cases -Method Post -Headers $h -ContentType application/json ` + -Body (@{ contactName = "Servicedesk"; contactEmail = "servicedesk@firma.dk"; findingId = 4711 } | ConvertTo-Json) +# senere: sagsnummer og status +Invoke-RestMethod "https://sqlinsight.domæne.local/api/v1/support-cases/$($sag.id)" -Headers $h +``` diff --git a/src/SQLInsight.Agent/Hub/HubSyncService.cs b/src/SQLInsight.Agent/Hub/HubSyncService.cs index 5e01f07..13b3e7a 100644 --- a/src/SQLInsight.Agent/Hub/HubSyncService.cs +++ b/src/SQLInsight.Agent/Hub/HubSyncService.cs @@ -163,6 +163,40 @@ public sealed partial class HubSyncService( } } + // Closings requested in SQLInsight Web, for cases the hub has given a number. + foreach (var item in await repository.GetDueSupportClosesAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false)) + { + var now = time.GetUtcNow(); + try + { + var body = new Contracts.SupportCaseCloseRequest(item.CloseComment, item.CloseRequestedBy); + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Url!, $"api/v1/cases/{Uri.EscapeDataString(item.Reference!)}/close")) + { + Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body, HubOutbox.Json), Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License); + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode && System.Text.Json.JsonSerializer.Deserialize(text, HubOutbox.Json) is { } dto) + { + await repository.MarkSupportClosedAsync(item.CaseId, dto.ClosedAt?.UtcDateTime, dto.ClosedBy, now, cancellationToken).ConfigureAwait(false); + created++; + continue; + } + + var error = $"{(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(text)}"; + var permanent = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + or HttpStatusCode.NotFound or HttpStatusCode.UnprocessableEntity; + await repository.MarkSupportCloseFailedAsync(item.CaseId, error, now, permanent ? null : now + Backoff(item.CloseAttempts), cancellationToken).ConfigureAwait(false); + LogCaseRejected(logger, item.Title, error); + } + catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + await repository.MarkSupportCloseFailedAsync(item.CaseId, ex.Message, now, now + Backoff(item.CloseAttempts), cancellationToken).ConfigureAwait(false); + LogCaseUnreachable(logger, ex.Message); + } + } + if (created == 0 && _casesFetchedAt is { } fetched && time.GetUtcNow() - fetched < CasesInterval) { return; @@ -181,7 +215,7 @@ public sealed partial class HubSyncService( var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var cases = System.Text.Json.JsonSerializer.Deserialize>(json, HubOutbox.Json) ?? []; await repository.SyncSupportCasesAsync( - cases.Select(c => new HubSupportCase(c.Reference, c.Title, c.IsOpen, c.CreatedAt.UtcDateTime, c.ClosedAt?.UtcDateTime, c.Source, c.ClientRequestId)), + cases.Select(c => new HubSupportCase(c.Reference, c.Title, c.IsOpen, c.CreatedAt.UtcDateTime, c.ClosedAt?.UtcDateTime, c.Source, c.ClientRequestId, c.ClosedBy)), time.GetUtcNow(), cancellationToken).ConfigureAwait(false); _casesFetchedAt = time.GetUtcNow(); } diff --git a/src/SQLInsight.Contracts/SupportCases.cs b/src/SQLInsight.Contracts/SupportCases.cs index edf5b74..fa66fba 100644 --- a/src/SQLInsight.Contracts/SupportCases.cs +++ b/src/SQLInsight.Contracts/SupportCases.cs @@ -70,7 +70,9 @@ public sealed record SupportCaseDto( DateTimeOffset CreatedAt, DateTimeOffset? ClosedAt, string Source, - Guid? ClientRequestId); + Guid? ClientRequestId, + string? ClosedBy = null, + string? ClosedComment = null); /// Who created a support case. public static class SupportCaseSources @@ -81,3 +83,15 @@ public static class SupportCaseSources /// The customer, in SQLInsight Web. public const string Customer = "Customer"; } + +/// +/// The customer closes a case (POST /api/v1/cases/{reference}/close), from SQLInsight Web or its API. Closing a closed +/// case changes nothing. +/// +public sealed record SupportCaseCloseRequest(string? Comment, string? RequestedBy) +{ + public const int MaxComment = 1000; + + public string? Validate() => Comment?.Length > MaxComment ? $"Kommentaren må højst være {MaxComment} tegn." + : RequestedBy?.Length > SupportCaseRequest.MaxRequestedBy ? "Brugernavnet er for langt." : null; +} diff --git a/src/SQLInsight.Hub/Api/CasesEndpoint.cs b/src/SQLInsight.Hub/Api/CasesEndpoint.cs index 40b87c9..16950e8 100644 --- a/src/SQLInsight.Hub/Api/CasesEndpoint.cs +++ b/src/SQLInsight.Hub/Api/CasesEndpoint.cs @@ -11,6 +11,7 @@ namespace SQLInsight.Hub.Api; /// Support cases for paying customers, through their agent: /// POST /api/v1/cases creates a case (idempotent on ClientRequestId) and returns its number; /// GET /api/v1/cases lists the customer's cases, also those SEOSoft created, so SQLInsight Web can show their status. +/// POST /api/v1/cases/{reference}/close closes one of the customer's own cases (idempotent). /// public static class CasesEndpoint { @@ -20,6 +21,7 @@ public static class CasesEndpoint { app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous(); app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous(); + app.MapPost("/api/v1/cases/{reference}/close", CloseAsync).DisableAntiforgery().AllowAnonymous(); } internal static async Task CreateAsync( @@ -138,5 +140,57 @@ public static class CasesEndpoint return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList()); } + internal static async Task CloseAsync( + string reference, HttpRequest request, HubDbContext db, IOptions options, TimeProvider time, CancellationToken cancellationToken) + { + var (license, rejected) = await FindingsEndpoint.AuthorizeAsync(request, db, options.Value, time, cancellationToken).ConfigureAwait(false); + if (license is null) + { + return rejected!; + } + + if (!license.IncludesPrioritySupport) + { + return FindingsEndpoint.Problem(403, "Licensen giver ikke support", "Supportsager kræver Professional eller Enterprise."); + } + + SupportCaseCloseRequest body; + try + { + body = request.ContentLength is null or 0 + ? new SupportCaseCloseRequest(null, null) + : await JsonSerializer.DeserializeAsync(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false) + ?? new SupportCaseCloseRequest(null, null); + } + catch (JsonException ex) + { + return FindingsEndpoint.Problem(400, "Ugyldig JSON", ex.Message); + } + + if (body.Validate() is { } invalid) + { + return FindingsEndpoint.Problem(422, "Ugyldig lukning", invalid); + } + + // Only the license's own customer's cases: anyone else's is simply not found. + var supportCase = await db.SupportCases + .SingleOrDefaultAsync(c => c.Customer!.Name == license.Customer && c.Reference == reference, cancellationToken).ConfigureAwait(false); + if (supportCase is null) + { + return FindingsEndpoint.Problem(404, "Ukendt sag", $"Sagen {reference} findes ikke."); + } + + if (supportCase.IsOpen) + { + supportCase.IsOpen = false; + supportCase.ClosedAt = time.GetUtcNow().UtcDateTime; + supportCase.ClosedBy = "Kunde: " + (string.IsNullOrWhiteSpace(body.RequestedBy) ? "ukendt" : body.RequestedBy.Trim()); + supportCase.ClosedComment = Trimmed(body.Comment); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + return Results.Ok(SupportCaseStore.ToDto(supportCase)); + } + private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/src/SQLInsight.Hub/Data/Entities.cs b/src/SQLInsight.Hub/Data/Entities.cs index 2cd64aa..1d3e1f5 100644 --- a/src/SQLInsight.Hub/Data/Entities.cs +++ b/src/SQLInsight.Hub/Data/Entities.cs @@ -104,6 +104,12 @@ public sealed class SupportCase public bool IsOpen { get; set; } = true; public DateTime CreatedAt { get; set; } public DateTime? ClosedAt { get; set; } + + /// Who closed it: "Kunde: DOMÆNE\bruger" (from SQLInsight Web or its API) or "SEOSoft: navn" (the portal). + public string? ClosedBy { get; set; } + + /// The comment given when the case was closed. + public string? ClosedComment { get; set; } public List Findings { get; } = []; /// Who created it: or . diff --git a/src/SQLInsight.Hub/Data/Migrations/20261002072808_CaseClosedBy.Designer.cs b/src/SQLInsight.Hub/Data/Migrations/20261002072808_CaseClosedBy.Designer.cs new file mode 100644 index 0000000..4f62d65 --- /dev/null +++ b/src/SQLInsight.Hub/Data/Migrations/20261002072808_CaseClosedBy.Designer.cs @@ -0,0 +1,594 @@ +// +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("20261002072808_CaseClosedBy")] + partial class CaseClosedBy + { + /// + 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("ClosedBy") + .HasColumnType("text") + .HasColumnName("closed_by"); + + b.Property("ClosedComment") + .HasColumnType("text") + .HasColumnName("closed_comment"); + + 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/20261002072808_CaseClosedBy.cs b/src/SQLInsight.Hub/Data/Migrations/20261002072808_CaseClosedBy.cs new file mode 100644 index 0000000..ad1e0f5 --- /dev/null +++ b/src/SQLInsight.Hub/Data/Migrations/20261002072808_CaseClosedBy.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SQLInsight.Hub.Data.Migrations +{ + /// + public partial class CaseClosedBy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "closed_by", + table: "support_cases", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "closed_comment", + table: "support_cases", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "closed_by", + table: "support_cases"); + + migrationBuilder.DropColumn( + name: "closed_comment", + table: "support_cases"); + } + } +} diff --git a/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs b/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs index 1f638b7..a343151 100644 --- a/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs +++ b/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs @@ -414,6 +414,14 @@ namespace SQLInsight.Hub.Data.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("closed_at"); + b.Property("ClosedBy") + .HasColumnType("text") + .HasColumnName("closed_by"); + + b.Property("ClosedComment") + .HasColumnType("text") + .HasColumnName("closed_comment"); + b.Property("ContactEmail") .HasMaxLength(320) .HasColumnType("character varying(320)") diff --git a/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor index f0dddd5..d1af163 100644 --- a/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor +++ b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor @@ -70,7 +70,7 @@ else { - + @foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt)) { @@ -81,6 +81,7 @@ + } diff --git a/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor b/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor index 18f4d6f..face709 100644 --- a/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor +++ b/src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor @@ -82,7 +82,7 @@ else {
SagTitelKildeStatusOprettetLukket
SagTitelKildeStatusOprettetLukketLukket af
@(c.IsOpen ? "Åben" : "Lukket") @c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) @c.ClosedAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)@c.ClosedBy@(c.ClosedComment is { } comment ? $": {comment}" : "")
- + @foreach (var c in _report.Cases) { @@ -93,6 +93,7 @@ + } diff --git a/src/SQLInsight.Hub/Portal/PortalEndpoints.cs b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs index 506a4b0..bee2ead 100644 --- a/src/SQLInsight.Hub/Portal/PortalEndpoints.cs +++ b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs @@ -41,7 +41,7 @@ public static class PortalEndpoints await db.SaveChangesAsync(ct); return Results.Redirect($"/portal/fund/{id}"); }); - portal.MapPost("/cases/{id:int}/close", async (int id, HubDbContext db, TimeProvider time, CancellationToken ct) => + portal.MapPost("/cases/{id:int}/close", async (int id, HttpContext http, HubDbContext db, TimeProvider time, CancellationToken ct) => { var supportCase = await db.SupportCases.FindAsync([id], ct); if (supportCase is null) @@ -49,8 +49,13 @@ public static class PortalEndpoints return Results.NotFound(); } - supportCase.IsOpen = false; - supportCase.ClosedAt = time.GetUtcNow().UtcDateTime; + if (supportCase.IsOpen) + { + supportCase.IsOpen = false; + supportCase.ClosedAt = time.GetUtcNow().UtcDateTime; + supportCase.ClosedBy = "SEOSoft: " + (http.User.Identity?.Name ?? "ukendt"); + } + await db.SaveChangesAsync(ct); return Results.Redirect("/portal/sager"); }); diff --git a/src/SQLInsight.Hub/Portal/PortalQueries.cs b/src/SQLInsight.Hub/Portal/PortalQueries.cs index eb9e9ea..fe37555 100644 --- a/src/SQLInsight.Hub/Portal/PortalQueries.cs +++ b/src/SQLInsight.Hub/Portal/PortalQueries.cs @@ -110,11 +110,12 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time) 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 }) + .Select(c => new { c.Id, c.CustomerId, Customer = c.Customer!.Name, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen, c.ClosedAt, c.ClosedBy }) .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)), + 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, + c.IsOpen ? null : c.ClosedBy)), from, to, Now); } diff --git a/src/SQLInsight.Hub/Support/SupportCaseStore.cs b/src/SQLInsight.Hub/Support/SupportCaseStore.cs index b5f4cbd..8f22e27 100644 --- a/src/SQLInsight.Hub/Support/SupportCaseStore.cs +++ b/src/SQLInsight.Hub/Support/SupportCaseStore.cs @@ -67,7 +67,8 @@ public static class SupportCaseStore 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); + return new SupportCaseDto(c.Reference, c.Title, c.IsOpen, Utc(c.CreatedAt), c.ClosedAt is { } closed ? Utc(closed) : null, c.Source, c.ClientRequestId, + c.ClosedBy, c.ClosedComment); } private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc)); diff --git a/src/SQLInsight.Hub/Support/SupportReport.cs b/src/SQLInsight.Hub/Support/SupportReport.cs index 98c25d6..a22926a 100644 --- a/src/SQLInsight.Hub/Support/SupportReport.cs +++ b/src/SQLInsight.Hub/Support/SupportReport.cs @@ -5,9 +5,14 @@ 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) +/// "Kunde: …" or "SEOSoft: …"; null for open cases and cases closed before it was recorded. +public sealed record SupportReportCase(int Id, int CustomerId, string Customer, string Reference, string Title, string Source, DateTime CreatedAt, DateTime? ClosedAt, + string? ClosedBy = null) { public TimeSpan? ResolutionTime => ClosedAt - CreatedAt; + + /// "Kunde" when the customer closed it, "SEOSoft" when staff did, empty while open. + public string ClosedByWhom => ClosedAt is null ? string.Empty : ClosedBy?.StartsWith("Kunde", StringComparison.Ordinal) == true ? "Kunde" : "SEOSoft"; } /// Key figures for one customer (or, for the total row, all customers). @@ -127,7 +132,7 @@ public static class SupportReport { ArgumentNullException.ThrowIfNull(report); var csv = new StringBuilder(); - csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Løsningstid (timer)\r\n"); + csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Lukket af;Løsningstid (timer)\r\n"); foreach (var c in report.Cases) { csv.AppendJoin(';', @@ -137,6 +142,7 @@ public static class SupportReport 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, + Field(c.ClosedByWhom), c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty); csv.Append("\r\n"); } diff --git a/src/SQLInsight.Repository/InsightRepository.Support.cs b/src/SQLInsight.Repository/InsightRepository.Support.cs index 8fffa14..a04a502 100644 --- a/src/SQLInsight.Repository/InsightRepository.Support.cs +++ b/src/SQLInsight.Repository/InsightRepository.Support.cs @@ -9,6 +9,9 @@ public static class SupportCaseStatus public const string Pending = "Pending"; public const string Open = "Open"; + /// Closing was requested in SQLInsight Web; the agent has not had it confirmed by the hub yet. + public const string CloseRequested = "CloseRequested"; + public const string Closed = "Closed"; /// The hub refused the request (e.g. no support in the license); see LastError. @@ -37,6 +40,11 @@ public sealed record SupportCaseRow public DateTime CreatedAt { get; init; } public DateTime? SentAt { get; init; } public DateTime? ClosedAt { get; init; } + public DateTime? CloseRequestedAt { get; init; } + public string? CloseRequestedBy { get; init; } + public string? CloseComment { get; init; } + public int CloseAttempts { get; init; } + public string? ClosedBy { get; init; } } /// A request filed in SQLInsight Web. @@ -54,7 +62,8 @@ public sealed record NewSupportRequest( string? Subject = null); /// A case as the hub reports it. -public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId); +public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId, + string? ClosedBy = null); public sealed partial class InsightRepository { @@ -102,7 +111,8 @@ public sealed partial class InsightRepository await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await connection.ExecuteAsync(""" UPDATE insight.SupportCases - SET Reference = @reference, Status = CASE WHEN @isOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @closedAt, + SET Reference = @reference, ClosedAt = @closedAt, + Status = CASE WHEN @isOpen = 0 THEN 'Closed' WHEN CloseRequestedAt IS NOT NULL THEN 'CloseRequested' ELSE 'Open' END, SentAt = @now, Attempts = Attempts + 1, LastError = NULL, UpdatedAt = @now WHERE CaseId = @caseId; """, new { caseId, reference, isOpen, closedAt, now = Utc(now) }).ConfigureAwait(false); @@ -132,13 +142,15 @@ public sealed partial class InsightRepository { await connection.ExecuteAsync(""" UPDATE insight.SupportCases - SET Reference = @Reference, Status = CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @ClosedAt, + SET Reference = @Reference, ClosedAt = @ClosedAt, ClosedBy = COALESCE(@ClosedBy, ClosedBy), + -- A close request not yet confirmed must not be turned back into Open by an older list from the hub. + Status = CASE WHEN @IsOpen = 0 THEN 'Closed' WHEN Status = 'CloseRequested' THEN Status ELSE 'Open' END, Title = CASE WHEN ClientRequestId IS NULL THEN @Title ELSE Title END, LastError = NULL, UpdatedAt = @now WHERE (@ClientRequestId IS NOT NULL AND ClientRequestId = @ClientRequestId) OR Reference = @Reference; IF @@ROWCOUNT = 0 INSERT insight.SupportCases (ClientRequestId, Reference, Title, Source, Status, CreatedAt, ClosedAt, SentAt, UpdatedAt) VALUES (@ClientRequestId, @Reference, @Title, @Source, CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, @CreatedAt, @ClosedAt, @now, @now); - """, new { c.Reference, c.Title, c.IsOpen, c.CreatedAt, c.ClosedAt, c.Source, c.ClientRequestId, now = Utc(now) }).ConfigureAwait(false); + """, new { c.Reference, c.Title, c.IsOpen, c.CreatedAt, c.ClosedAt, c.Source, c.ClientRequestId, c.ClosedBy, now = Utc(now) }).ConfigureAwait(false); } } } diff --git a/src/SQLInsight.Repository/InsightRepository.SupportApi.cs b/src/SQLInsight.Repository/InsightRepository.SupportApi.cs new file mode 100644 index 0000000..35087a6 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.SupportApi.cs @@ -0,0 +1,120 @@ +using Dapper; + +namespace SQLInsight.Repository; + +public enum SupportCloseOutcome +{ + NotFound, + + /// Closed already: nothing to do. + AlreadyClosed, + + /// Recorded; the agent sends it (at once, or once the hub has created the case). + Requested, + + /// The hub refused the case; it cannot be closed. + Failed, +} + +public sealed partial class InsightRepository +{ + public async Task GetSupportCaseAsync(long caseId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync("SELECT * FROM insight.SupportCases WHERE CaseId = @caseId;", new { caseId }) + .ConfigureAwait(false); + } + + public async Task GetSupportCaseByRequestIdAsync(Guid clientRequestId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync( + "SELECT * FROM insight.SupportCases WHERE ClientRequestId = @clientRequestId;", new { clientRequestId }).ConfigureAwait(false); + } + + /// The customer's cases, newest first: optionally by status and created in [from, to]; one page and whether there are more. + public async Task<(IReadOnlyList Cases, bool More)> SearchSupportCasesAsync(string? status, DateTimeOffset? from, DateTimeOffset? to, + int offset, int limit, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = (await connection.QueryAsync(""" + SELECT * FROM insight.SupportCases + WHERE (@status IS NULL OR Status = @status) AND (@from IS NULL OR CreatedAt >= @from) AND (@to IS NULL OR CreatedAt <= @to) + ORDER BY CreatedAt DESC, CaseId DESC + OFFSET @offset ROWS FETCH NEXT @take ROWS ONLY; + """, new { status, from = from?.UtcDateTime, to = to?.UtcDateTime, offset = Math.Max(0, offset), take = limit + 1 }).ConfigureAwait(false)).ToList(); + return (rows.Take(limit).ToList(), rows.Count > limit); + } + + /// + /// Records that the customer wants a case closed (SQLInsight Web: UPDATE on the close columns only, see + /// 0012_support_close.sql). An open case becomes CloseRequested; a pending one keeps its status and is closed once + /// the hub has created it. + /// + public async Task RequestSupportCloseAsync(long caseId, string? requestedBy, string? comment, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var status = await connection.QuerySingleOrDefaultAsync("SELECT Status FROM insight.SupportCases WHERE CaseId = @caseId;", new { caseId }) + .ConfigureAwait(false); + switch (status) + { + case null: + return SupportCloseOutcome.NotFound; + case SupportCaseStatus.Closed: + return SupportCloseOutcome.AlreadyClosed; + case SupportCaseStatus.Failed: + return SupportCloseOutcome.Failed; + } + + await connection.ExecuteAsync(""" + UPDATE insight.SupportCases + SET CloseRequestedAt = COALESCE(CloseRequestedAt, @now), CloseRequestedBy = COALESCE(CloseRequestedBy, @requestedBy), + CloseComment = COALESCE(@comment, CloseComment), CloseNextAttemptAt = @now, + Status = CASE WHEN Status = 'Open' THEN 'CloseRequested' ELSE Status END, UpdatedAt = @now + WHERE CaseId = @caseId AND Status IN ('Pending', 'Open', 'CloseRequested'); + """, new { caseId, requestedBy = Cut(requestedBy, 256), comment = Cut(comment, 1000), now = Utc(now) }).ConfigureAwait(false); + return SupportCloseOutcome.Requested; + + static string? Cut(string? value, int max) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().Length <= max ? value.Trim() : value.Trim()[..max]; + } + + /// Close requests the agent should send now: cases with a number whose closing is not confirmed. + public async Task> GetDueSupportClosesAsync(DateTimeOffset now, int max = 10, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT TOP (@max) * FROM insight.SupportCases + WHERE Status = 'CloseRequested' AND Reference IS NOT NULL AND (CloseNextAttemptAt IS NULL OR CloseNextAttemptAt <= @now) + ORDER BY CloseRequestedAt; + """, new { max, now = Utc(now) }).ConfigureAwait(false); + return rows.ToList(); + } + + /// The hub confirmed the closing. + public async Task MarkSupportClosedAsync(long caseId, DateTime? closedAt, string? closedBy, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.SupportCases + SET Status = 'Closed', ClosedAt = COALESCE(@closedAt, @now), ClosedBy = @closedBy, CloseAttempts = CloseAttempts + 1, LastError = NULL, UpdatedAt = @now + WHERE CaseId = @caseId; + """, new { caseId, closedAt, closedBy, now = Utc(now) }).ConfigureAwait(false); + } + + /// + /// A failed attempt to close. With null the hub refused for good: the case is open + /// again with the reason in LastError. + /// + public async Task MarkSupportCloseFailedAsync(long caseId, string error, DateTimeOffset now, DateTimeOffset? nextAttemptAt, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.SupportCases + SET CloseAttempts = CloseAttempts + 1, LastError = LEFT(@error, 1000), UpdatedAt = @now, CloseNextAttemptAt = @next, + Status = CASE WHEN @next IS NULL THEN 'Open' ELSE Status END, + CloseRequestedAt = CASE WHEN @next IS NULL THEN NULL ELSE CloseRequestedAt END + WHERE CaseId = @caseId; + """, new { caseId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false); + } +} diff --git a/src/SQLInsight.Repository/Migrations/0012_support_close.sql b/src/SQLInsight.Repository/Migrations/0012_support_close.sql new file mode 100644 index 0000000..c3108d0 --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0012_support_close.sql @@ -0,0 +1,16 @@ +/* Closing support cases from SQLInsight Web (the /support page or POST /api/v1/support-cases/{id}/close): the web + UI records the request, the agent sends it to SQLInsight Hub and sets Closed when the hub has confirmed it. A request + on a case without a number yet is sent once the hub has created the case. */ +ALTER TABLE insight.SupportCases ADD + CloseRequestedAt datetime2(0) NULL, + CloseRequestedBy nvarchar(256) NULL, + CloseComment nvarchar(1000) NULL, + CloseAttempts int NOT NULL CONSTRAINT DF_SupportCases_CloseAttempts DEFAULT (0), + CloseNextAttemptAt datetime2(0) NULL, + ClosedBy nvarchar(300) NULL; +GO +ALTER TABLE insight.SupportCases DROP CONSTRAINT CK_SupportCases_Status; +ALTER TABLE insight.SupportCases ADD CONSTRAINT CK_SupportCases_Status CHECK (Status IN ('Pending', 'Open', 'CloseRequested', 'Closed', 'Failed')); +GO +/* Like INSERT in 0005: the web UI may record a close request on these columns only. */ +GRANT UPDATE ON insight.SupportCases (CloseRequestedAt, CloseRequestedBy, CloseComment, CloseNextAttemptAt, Status, UpdatedAt) TO public; diff --git a/src/SQLInsight.Web/Api/SupportApiEndpoints.cs b/src/SQLInsight.Web/Api/SupportApiEndpoints.cs new file mode 100644 index 0000000..9c46999 --- /dev/null +++ b/src/SQLInsight.Web/Api/SupportApiEndpoints.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using SQLInsight.Repository; + +namespace SQLInsight.Web.Api; + +/// Body of POST /api/v1/support-cases. +public sealed record SupportCaseCreateBody(string? Title, string? Description, string? ContactName, string? ContactEmail, string? ContactPhone, long? FindingId); + +/// Body of POST /api/v1/support-cases/{id}/close. +public sealed record SupportCaseCloseBody(string? Comment); + +/// +/// Support cases through the API, e.g. from an ITSM system: create, list, read and close. The same logic as the +/// /support page; the agent sends them to SQLInsight Hub. Writing needs the scope support:write for tokens. +/// +public static class SupportApiEndpoints +{ + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + var api = app.MapGroup("/api/v1/support-cases").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead(); + api.MapPost("", CreateAsync) + .WithSummary("Opret en supportsag / Create a support case") + .WithDescription("202 Accepted with the local id and status Pending; the case number (SUP-…) follows when the agent has heard from SQLInsight Hub. " + + "Idempotency-Key header: the same key returns the same case. Needs scope support:write for tokens."); + api.MapGet("", ListAsync).WithSummary("Kundens supportsager / Support cases (status, from, to, limit, continuation)"); + api.MapGet("/{id:long}", GetAsync).WithSummary("Én supportsag med status og sagsnummer / One support case"); + api.MapPost("/{id:long}/close", CloseAsync) + .WithSummary("Luk en supportsag / Close a support case") + .WithDescription("Status CloseRequested until the hub has confirmed it, then Closed. Closing a closed case returns 200. Needs scope support:write for tokens."); + } + + private static IResult Forbidden(string detail) => + Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt", detail: detail); + + private static async Task CreateAsync(SupportCaseCreateBody body, HttpContext http, InsightRepository repository, CancellationToken ct) + { + if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite)) + { + return Forbidden("API-tokenet mangler scope \"support:write\"."); + } + + var key = http.Request.Headers["Idempotency-Key"].ToString(); + var clientRequestId = key.Length == 0 ? Guid.NewGuid() : RequestId(key); + var result = await Support.FileAsync(repository, clientRequestId, body.Title, body.Description, body.ContactName, body.ContactEmail, body.ContactPhone, + body.FindingId, http.User.Identity?.Name, ct).ConfigureAwait(false); + if (result.NotAllowed) + { + return Forbidden(Support.NotAllowedText); + } + + if (result.Error is { } error) + { + return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig sag", detail: error); + } + + var row = (await repository.GetSupportCaseAsync(result.CaseId!.Value, ct).ConfigureAwait(false))!; + return Results.Accepted($"/api/v1/support-cases/{row.CaseId}", Json(row)); + } + + /// An Idempotency-Key as the request id: a GUID as it is, anything else hashed to one. + public static Guid RequestId(string key) + { + ArgumentNullException.ThrowIfNull(key); + return Guid.TryParse(key, out var guid) ? guid : new Guid(SHA256.HashData(Encoding.UTF8.GetBytes(key)).AsSpan(0, 16)); + } + + private static async Task ListAsync(InsightRepository repository, HttpContext http, CancellationToken ct, + string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, int? limit = null, string? continuation = null) + { + var offset = 0; + if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset)) + { + return Results.BadRequest("Ugyldig continuation."); + } + + var size = Math.Clamp(limit ?? 100, 1, 1000); + var (cases, more) = await repository.SearchSupportCasesAsync(status, from, to, offset, size, ct).ConfigureAwait(false); + if (more) + { + http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture); + } + + return Results.Ok(cases.Select(Json)); + } + + private static async Task GetAsync(long id, InsightRepository repository, CancellationToken ct) => + await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound(); + + private static async Task CloseAsync(long id, HttpContext http, InsightRepository repository, CancellationToken ct, SupportCaseCloseBody? body = null) + { + if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite)) + { + return Forbidden("API-tokenet mangler scope \"support:write\"."); + } + + if (body?.Comment?.Length > Contracts.SupportCaseCloseRequest.MaxComment) + { + return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig lukning", detail: $"Kommentaren må højst være {Contracts.SupportCaseCloseRequest.MaxComment} tegn."); + } + + var outcome = await repository.RequestSupportCloseAsync(id, http.User.Identity?.Name, body?.Comment, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); + if (outcome == SupportCloseOutcome.NotFound) + { + return Results.NotFound(); + } + + if (outcome == SupportCloseOutcome.Failed) + { + return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Sagen kan ikke lukkes", detail: "SQLInsight Hub afviste sagen, så den findes ikke hos SEOSoft."); + } + + var row = (await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false))!; + return outcome == SupportCloseOutcome.AlreadyClosed ? Results.Ok(Json(row)) : Results.Accepted($"/api/v1/support-cases/{id}", Json(row)); + } + + private static object Json(SupportCaseRow c) => new + { + Id = c.CaseId, + c.Reference, + c.Title, + c.Status, + c.Source, + c.FindingId, + c.InstanceName, + c.RuleId, + c.RequestedBy, + c.CreatedAt, + c.SentAt, + c.CloseRequestedAt, + c.ClosedAt, + c.ClosedBy, + Error = c.Status == SupportCaseStatus.Failed ? c.LastError : null, + }; +} diff --git a/src/SQLInsight.Web/Components/Pages/SupportPage.razor b/src/SQLInsight.Web/Components/Pages/SupportPage.razor index bb17942..95eb01e 100644 --- a/src/SQLInsight.Web/Components/Pages/SupportPage.razor +++ b/src/SQLInsight.Web/Components/Pages/SupportPage.razor @@ -6,12 +6,16 @@ Support · SQLInsight Pro

Support

-

Supportsager hos SEOSoft. Sagen sendes af agenten til SQLInsight Hub, som giver den et sagsnummer. Status opdateres her, når SEOSoft lukker sagen.

+

Supportsager hos SEOSoft. Sagen sendes af agenten til SQLInsight Hub, som giver den et sagsnummer. Status opdateres her, når sagen lukkes, af SEOSoft eller jer selv.

@if (Sendt == 1) {

Sagen er sendt. Den får et sagsnummer, så snart agenten har været i kontakt med SQLInsight Hub (normalt inden for et minut).

} +@if (Lukket == 1) +{ +

Lukningen er sendt. Sagen står som lukket, når SQLInsight Hub har bekræftet den (normalt inden for et minut).

+} @if (!string.IsNullOrEmpty(Fejl)) { @@ -58,7 +62,7 @@ else else {
SagKundeTitelKildeOprettetLukketLøsningstid
SagKundeTitelKildeOprettetLukketLukket afLøsningstid
@(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.ClosedByWhom @(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))
- + @foreach (var c in _cases) { @@ -68,6 +72,20 @@ else + } @@ -79,6 +97,7 @@ else [CascadingParameter] private HttpContext? Http { get; set; } [SupplyParameterFromQuery(Name = "fund")] public long? Fund { get; set; } [SupplyParameterFromQuery(Name = "sendt")] public int? Sendt { get; set; } + [SupplyParameterFromQuery(Name = "lukket")] public int? Lukket { get; set; } [SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; } private HubStatusRow? _hub; diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs index edca889..e2ceb95 100644 --- a/src/SQLInsight.Web/Program.cs +++ b/src/SQLInsight.Web/Program.cs @@ -115,6 +115,7 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit IntegrationEndpoints.Map(app); ApiV1Endpoints.Map(app); +SupportApiEndpoints.Map(app); app.MapOpenApi("/api/v1/openapi.json").RequireRead(); Support.Map(app); diff --git a/src/SQLInsight.Web/Support.cs b/src/SQLInsight.Web/Support.cs index 2d1b539..db1bfeb 100644 --- a/src/SQLInsight.Web/Support.cs +++ b/src/SQLInsight.Web/Support.cs @@ -11,7 +11,12 @@ namespace SQLInsight.Web; /// public static class Support { - public static void Map(IEndpointRouteBuilder app) => app.MapPost("/support", CreateAsync); + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + app.MapPost("/support", CreateAsync); + app.MapPost("/support/{id:long}/luk", CloseAsync); + } /// Title and description for a case about . Sensitive measurements are left out. public static (string Title, string Description) Prefill(StoredFinding finding) @@ -54,35 +59,96 @@ public static class Support { SupportCaseStatus.Pending => row.Attempts > 0 ? "Venter på hubben (prøver igen)" : "Sendes", SupportCaseStatus.Open => "Åben", + SupportCaseStatus.CloseRequested => "Lukning sendes", SupportCaseStatus.Closed => "Lukket", _ => "Afvist", }; } + /// What filing a request came to. + /// The same request (Idempotency-Key) was filed before: this is that case. + public sealed record FileResult(long? CaseId, string? Error, bool NotAllowed = false, bool Existing = false); + + public const string NotAllowedText = "Supportsager kræver Professional eller Enterprise og at deling med SQLInsight Hub er slået til."; + + /// + /// Files a support request, from the form or the API: the same checks, and the same non-sensitive summary of the + /// finding it is about. The agent sends it to the hub. + /// + public static async Task FileAsync(InsightRepository repository, Guid clientRequestId, string? title, string? description, + string? contactName, string? contactEmail, string? contactPhone, long? findingId, string? requestedBy, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + if ((await repository.GetHubStatusAsync(cancellationToken).ConfigureAwait(false))?.AllowsSupportCases != true) + { + return new FileResult(null, NotAllowedText, NotAllowed: true); + } + + if (await repository.GetSupportCaseByRequestIdAsync(clientRequestId, cancellationToken).ConfigureAwait(false) is { } earlier) + { + return new FileResult(earlier.CaseId, null, Existing: true); + } + + var finding = findingId is { } fid ? await repository.GetFindingAsync(fid, cancellationToken).ConfigureAwait(false) : null; + if (findingId is not null && finding is null) + { + return new FileResult(null, $"Fundet {findingId} findes ikke."); + } + + if (finding is not null) + { + var (prefilledTitle, prefilledDescription) = Prefill(finding); + title = string.IsNullOrWhiteSpace(title) ? prefilledTitle : title; + description = string.IsNullOrWhiteSpace(description) ? prefilledDescription : description.Trim() + "\n\n---\n" + prefilledDescription; + description = description.Length > SupportCaseRequest.MaxDescription ? description[..SupportCaseRequest.MaxDescription] : description; + } + + var request = new SupportCaseRequest(clientRequestId, title?.Trim() ?? string.Empty, Blank(description), contactName?.Trim() ?? string.Empty, + contactEmail?.Trim() ?? string.Empty, Blank(contactPhone), requestedBy, finding?.InstanceName, finding?.RuleId, finding?.Subject); + if (request.Validate() is { } invalid) + { + return new FileResult(null, invalid); + } + + try + { + var id = await repository.AddSupportRequestAsync(new NewSupportRequest(request.ClientRequestId, request.Title, request.Description, request.ContactName, + request.ContactEmail, request.ContactPhone, request.RequestedBy, finding?.FindingId, finding?.InstanceName, finding?.RuleId, finding?.Subject), + DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + return new FileResult(id, null); + } + catch (Microsoft.Data.SqlClient.SqlException ex) when (ex.Number is 2601 or 2627) + { + // The same Idempotency-Key at the same moment: the other request won. + var other = await repository.GetSupportCaseByRequestIdAsync(clientRequestId, cancellationToken).ConfigureAwait(false); + return new FileResult(other?.CaseId, other is null ? ex.Message : null, Existing: other is not null); + } + } + private static async Task CreateAsync( HttpContext http, InsightRepository repository, [FromForm] string? title, [FromForm] string? description, [FromForm] string? contactName, [FromForm] string? contactEmail, [FromForm] string? contactPhone, [FromForm] long? findingId) { var back = findingId is { } id ? $"&fund={id}" : string.Empty; - if ((await repository.GetHubStatusAsync(http.RequestAborted))?.AllowsSupportCases != true) - { - return Results.Redirect("/support?fejl=" + Uri.EscapeDataString("Supportsager kræver Professional eller Enterprise og at deling med SQLInsight Hub er slået til.")); - } - - var finding = findingId is { } fid ? await repository.GetFindingAsync(fid, http.RequestAborted) : null; - var request = new SupportCaseRequest(Guid.NewGuid(), title?.Trim() ?? string.Empty, Blank(description), contactName?.Trim() ?? string.Empty, - contactEmail?.Trim() ?? string.Empty, Blank(contactPhone), http.User.Identity?.Name, finding?.InstanceName, finding?.RuleId, finding?.Subject); - if (request.Validate() is { } invalid) - { - return Results.Redirect("/support?fejl=" + Uri.EscapeDataString(invalid) + back); - } - - await repository.AddSupportRequestAsync(new NewSupportRequest(request.ClientRequestId, request.Title, request.Description, request.ContactName, - request.ContactEmail, request.ContactPhone, request.RequestedBy, finding?.FindingId, finding?.InstanceName, finding?.RuleId, finding?.Subject), - DateTimeOffset.UtcNow, http.RequestAborted); - return Results.Redirect("/support?sendt=1"); + var result = await FileAsync(repository, Guid.NewGuid(), title, description, contactName, contactEmail, contactPhone, findingId, + http.User.Identity?.Name, http.RequestAborted); + return result.Error is { } error + ? Results.Redirect("/support?fejl=" + Uri.EscapeDataString(error) + (result.NotAllowed ? string.Empty : back)) + : Results.Redirect("/support?sendt=1"); } - private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + /// The "Luk sag" button on /support. + private static async Task CloseAsync(long id, HttpContext http, InsightRepository repository, [FromForm] string? comment) + { + var outcome = await repository.RequestSupportCloseAsync(id, http.User.Identity?.Name, comment, DateTimeOffset.UtcNow, http.RequestAborted); + return outcome switch + { + SupportCloseOutcome.NotFound => Results.NotFound(), + SupportCloseOutcome.Failed => Results.Redirect("/support?fejl=" + Uri.EscapeDataString("Sagen blev afvist af SQLInsight Hub og kan ikke lukkes.")), + _ => Results.Redirect("/support?lukket=1"), + }; + } + + public static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/tests/SQLInsight.IntegrationTests/SupportApiTests.cs b/tests/SQLInsight.IntegrationTests/SupportApiTests.cs new file mode 100644 index 0000000..c749bf3 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/SupportApiTests.cs @@ -0,0 +1,213 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SQLInsight.Agent.Hub; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Hub.Data; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; +using SQLInsight.Web.Api; + +namespace SQLInsight.IntegrationTests; + +/// Support cases through the API of SQLInsight Web: created and closed, through the agent to the hub and back. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class SupportApiTests(SqlServerFixture sql, HubFixture hub) : IClassFixture, IAsyncLifetime +{ + private const string WriteToken = "sqli_test-itsm-write"; + private const string ReadToken = "sqli_test-read-only"; + + private readonly string _database = "SQLInsightSupApi_" + Guid.NewGuid().ToString("N")[..8]; + private InsightRepository _repository = null!; + private long _findingId; + + public async Task InitializeAsync() + { + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + var id = await _repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo + { + ServerName = "SQL01", Version = SqlServerVersion.Parse("15.0.4355.3"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard, + }, "SqlClient", false, DateTimeOffset.UtcNow); + await _repository.SaveRuleResultsAsync(id, + [ + new RuleResult + { + RuleId = "SQLI-CFG-001", RuleTitle = "max server memory", Outcome = RuleOutcome.Findings, + Findings = + [ + new Finding + { + RuleId = "SQLI-CFG-001", Severity = Severity.High, Title = "max server memory er ikke sat", Explanation = "SQL Server må bruge al hukommelse.", + Recommendation = "Sæt den.", Evidence = [new Evidence("max server memory (MB)", "2147483647"), new Evidence("login", "KUNDE\\hemmelig", Sensitive: true)], + }, + ], + }, + ], DateTimeOffset.UtcNow); + _findingId = (await _repository.GetActiveFindingsAsync(id)).Single().FindingId; + } + + public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + + private Task AllowSupportAsync() => _repository.SaveHubStatusAsync(new HubStatusRow + { + Enabled = true, HubUrl = "http://localhost/", LicenseState = "Valid", LicenseMessage = "Professional", Customer = "Kunde A/S", + LicensePlan = "Professional", LicenseExpires = DateTime.UtcNow.AddYears(1), UpdatedAt = DateTime.UtcNow, + }); + + private sealed class TestServerClients(Func create) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => create(); + } + + private HubSyncService Sync(string license) + { + var options = Options.Create(new SQLInsight.Agent.Hub.HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = license }); + options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey; + return new HubSyncService(options, _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger.Instance); + } + + private WebApplicationFactory Web() => new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); + b.UseSetting("SQLInsight:Web:Authentication", "None"); + }); + + private static StringContent Body(object value) => new(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json"); + + [Fact] + public async Task Case_created_and_closed_through_the_api_reaches_the_hub_and_its_report() + { + var customer = $"Support API {Guid.NewGuid():N}"[..28]; + var license = hub.License(customer); + await AllowSupportAsync(); + await using var web = Web(); + var client = web.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/support-cases") + { + Content = Body(new { contactName = "Anna", contactEmail = "anna@kunde.dk", findingId = _findingId, description = "Siden mandag." }), + }; + request.Headers.Add("Idempotency-Key", "itsm-INC0012345"); + var created = await client.SendAsync(request); + var again = new HttpRequestMessage(HttpMethod.Post, "/api/v1/support-cases") { Content = Body(new { title = "Andet", contactName = "Anna", contactEmail = "anna@kunde.dk" }) }; + again.Headers.Add("Idempotency-Key", "itsm-INC0012345"); + var repeated = await client.SendAsync(again); + + Assert.Equal(HttpStatusCode.Accepted, created.StatusCode); + var body = JsonDocument.Parse(await created.Content.ReadAsStringAsync()).RootElement; + var id = body.GetProperty("id").GetInt64(); + Assert.Equal("Pending", body.GetProperty("status").GetString()); + Assert.Equal($"/api/v1/support-cases/{id}", created.Headers.Location!.OriginalString); + Assert.Equal(id, JsonDocument.Parse(await repeated.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetInt64()); + var stored = (await _repository.GetSupportCaseAsync(id))!; + Assert.Equal("max server memory er ikke sat", stored.Title); // from the finding + Assert.Contains("Siden mandag.", stored.Description, StringComparison.Ordinal); + Assert.Contains("2147483647", stored.Description, StringComparison.Ordinal); + Assert.DoesNotContain("hemmelig", stored.Description, StringComparison.Ordinal); // sensitive evidence left out + + await Sync(license).RunOnceAsync(CancellationToken.None); + var numbered = JsonDocument.Parse(await client.GetStringAsync($"/api/v1/support-cases/{id}")).RootElement; + Assert.StartsWith("SUP-", numbered.GetProperty("reference").GetString(), StringComparison.Ordinal); + Assert.Equal("Open", numbered.GetProperty("status").GetString()); + + var close = await client.PostAsync($"/api/v1/support-cases/{id}/close", Body(new { comment = "Løst ved at sætte max server memory." })); + Assert.Equal(HttpStatusCode.Accepted, close.StatusCode); + Assert.Equal("CloseRequested", JsonDocument.Parse(await close.Content.ReadAsStringAsync()).RootElement.GetProperty("status").GetString()); + + await Sync(license).RunOnceAsync(CancellationToken.None); + + var closed = JsonDocument.Parse(await client.GetStringAsync($"/api/v1/support-cases/{id}")).RootElement; + Assert.Equal("Closed", closed.GetProperty("status").GetString()); + Assert.StartsWith("Kunde:", closed.GetProperty("closedBy").GetString(), StringComparison.Ordinal); + Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/support-cases/{id}/close", Body(new { }))).StatusCode); // idempotent + Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync("/api/v1/support-cases/999999/close", Body(new { }))).StatusCode); + var list = JsonDocument.Parse(await client.GetStringAsync("/api/v1/support-cases?status=Closed")).RootElement; + Assert.Equal(1, list.GetArrayLength()); + + await using var scope = hub.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var atHub = await db.SupportCases.SingleAsync(c => c.Customer!.Name == customer); + Assert.False(atHub.IsOpen); + Assert.Equal("Løst ved at sætte max server memory.", atHub.ClosedComment); + var report = await new SQLInsight.Hub.Portal.PortalQueries(db, TimeProvider.System).SupportReportAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), atHub.CustomerId); + Assert.Equal("Kunde", Assert.Single(report.Cases).ClosedByWhom); + } + + [Fact] + public async Task Hub_closes_only_the_license_customers_own_cases() + { + var owner = hub.License($"Ejer {Guid.NewGuid():N}"[..20]); + var stranger = hub.License($"Fremmed {Guid.NewGuid():N}"[..20]); + await _repository.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Hjælp", null, "Anna", "anna@kunde.dk", null, null), DateTimeOffset.UtcNow); + await Sync(owner).RunOnceAsync(CancellationToken.None); + var reference = (await _repository.GetSupportCasesAsync()).Single().Reference!; + + var client = hub.Factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("SQLInsight-License", stranger); + var response = await client.PostAsync($"/api/v1/cases/{reference}/close", JsonContent.Create(new { comment = "nej" })); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Tokens_need_the_support_scope_and_a_support_license() + { + int port; + using (var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0)) + { + probe.Start(); + port = ((IPEndPoint)probe.LocalEndpoint).Port; + } + + await using var web = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); + b.UseSetting("SQLInsight:Web:Authentication", "Windows"); + b.UseSetting("SQLInsight:Web:ApiTokens:itsm:hash", "sha256:" + ApiTokenHandler.Hash(WriteToken)); + b.UseSetting("SQLInsight:Web:ApiTokens:itsm:scopes:0", "read"); + b.UseSetting("SQLInsight:Web:ApiTokens:itsm:scopes:1", "support:write"); + b.UseSetting("SQLInsight:Web:ApiTokens:grafana", "sha256:" + ApiTokenHandler.Hash(ReadToken)); + b.UseSetting("urls", $"http://127.0.0.1:{port}"); + }); + web.UseKestrel(); + web.StartServer(); + HttpClient Client(string token) + { + var c = web.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri($"http://127.0.0.1:{port}") }); + c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return c; + } + + var case_ = new { title = "Fra ITSM", contactName = "Anna", contactEmail = "anna@kunde.dk" }; + var withoutLicense = await Client(WriteToken).PostAsync("/api/v1/support-cases", Body(case_)); + await AllowSupportAsync(); + var readOnly = await Client(ReadToken).PostAsync("/api/v1/support-cases", Body(case_)); + var allowed = await Client(WriteToken).PostAsync("/api/v1/support-cases", Body(case_)); + var id = JsonDocument.Parse(await allowed.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetInt64(); + var readOnlyClose = await Client(ReadToken).PostAsync($"/api/v1/support-cases/{id}/close", Body(new { })); + var readOnlyList = await Client(ReadToken).GetAsync("/api/v1/support-cases"); + var writeOnlyPages = await Client(WriteToken).GetAsync("/support"); + + Assert.Equal(HttpStatusCode.Forbidden, withoutLicense.StatusCode); + Assert.Equal("application/problem+json", withoutLicense.Content.Headers.ContentType?.MediaType); + Assert.Equal(HttpStatusCode.Forbidden, readOnly.StatusCode); + Assert.Equal(HttpStatusCode.Accepted, allowed.StatusCode); + Assert.Equal("token:itsm", (await _repository.GetSupportCaseAsync(id))!.RequestedBy); + Assert.Equal(HttpStatusCode.Forbidden, readOnlyClose.StatusCode); + Assert.Equal(HttpStatusCode.OK, readOnlyList.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, writeOnlyPages.StatusCode); // a token never opens the web pages + } +} diff --git a/tests/SQLInsight.IntegrationTests/WebApiTests.cs b/tests/SQLInsight.IntegrationTests/WebApiTests.cs index 81b79ea..cb8e1f4 100644 --- a/tests/SQLInsight.IntegrationTests/WebApiTests.cs +++ b/tests/SQLInsight.IntegrationTests/WebApiTests.cs @@ -290,7 +290,7 @@ public class ApiV1Tests(SqlServerFixture sql) : IAsyncLifetime .OfType() .Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/')) .Where(p => p.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase) && !p.EndsWith("openapi.json", StringComparison.OrdinalIgnoreCase)) - .Select(p => System.Text.RegularExpressions.Regex.Replace(p, @"\{([^}:]+)(:[^}]+)?\}", "{$1}")) + .Select(p => System.Text.RegularExpressions.Regex.Replace(p, @"\{([^}:]+)(:[^}]+)?\}", "{$1}").TrimEnd('/')) .Distinct() .ToList(); diff --git a/tests/SQLInsight.IntegrationTests/WebSupportTests.cs b/tests/SQLInsight.IntegrationTests/WebSupportTests.cs index 789701f..e383100 100644 --- a/tests/SQLInsight.IntegrationTests/WebSupportTests.cs +++ b/tests/SQLInsight.IntegrationTests/WebSupportTests.cs @@ -151,6 +151,12 @@ public partial class WebSupportTests(SqlServerFixture sql) : IAsyncLifetime Assert.True(id > 0); await Assert.ThrowsAsync(() => readOnly.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow)); + + // Closing is only a request on the close columns: allowed with the same read-only login. + await _repository.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow); + Assert.Equal(SupportCloseOutcome.Requested, await readOnly.RequestSupportCloseAsync(id, "KUNDE\\anna", "Løst", DateTimeOffset.UtcNow)); + Assert.Equal(SupportCaseStatus.CloseRequested, (await readOnly.GetSupportCaseAsync(id))!.Status); + await Assert.ThrowsAsync(() => readOnly.MarkSupportClosedAsync(id, DateTime.UtcNow, "Kunde: x", DateTimeOffset.UtcNow)); } [Fact]
SagTitelStatusOprettetLukket
SagTitelStatusOprettetLukket
@Support.StatusText(c)@if (c.Status == SupportCaseStatus.Failed && c.LastError is { } error) {
@error
}
@Display.Time(c.CreatedAt) @Display.Time(c.ClosedAt) + @if (c.Status is SupportCaseStatus.Open or SupportCaseStatus.Pending && c.CloseRequestedAt is null) + { +
+ + + + + } + else if (c.ClosedBy is { } by) + { + @by + } +