Support cases through the API: create, read and close

- POST/GET /api/v1/support-cases[/{id}] and POST .../{id}/close in
  SQLInsight Web, sharing the /support form's logic: same checks, finding
  prefill without sensitive evidence, Idempotency-Key as the request id,
  202 Accepted with Location, problem+json 403 without the support:write
  scope or a support license. RequestedBy is the Windows user or
  token:<name>.
- Closing: "Luk sag" on /support and the API record CloseRequested
  (migration 0012, column-level UPDATE for the read-only web login); the
  agent sends it to the hub with backoff and sets Closed when confirmed;
  a pending case is closed once it has a number; an older hub list never
  turns CloseRequested back to Open.
- Hub: POST /api/v1/cases/{reference}/close for the license's own cases
  (others 404), idempotent, ClosedBy/ClosedComment (EF migration
  CaseClosedBy). Portal closing records "SEOSoft: <user>"; the customer
  page shows who closed and why; the support report and CSV get "Lukket af".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 09:47:58 +02:00
parent 30d89acbd7
commit f3551b1bf0
26 changed files with 1417 additions and 38 deletions

View file

@ -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). 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: <bruger>"` og kommentaren; lukning i portalen giver `ClosedBy = "SEOSoft: <medarbejder>"`. 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.

View file

@ -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/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/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 | | `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 | | `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. **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.

View file

@ -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. 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). 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
```

View file

@ -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<Contracts.SupportCaseDto>(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) if (created == 0 && _casesFetchedAt is { } fetched && time.GetUtcNow() - fetched < CasesInterval)
{ {
return; return;
@ -181,7 +215,7 @@ public sealed partial class HubSyncService(
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var cases = System.Text.Json.JsonSerializer.Deserialize<List<Contracts.SupportCaseDto>>(json, HubOutbox.Json) ?? []; var cases = System.Text.Json.JsonSerializer.Deserialize<List<Contracts.SupportCaseDto>>(json, HubOutbox.Json) ?? [];
await repository.SyncSupportCasesAsync( 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); time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
_casesFetchedAt = time.GetUtcNow(); _casesFetchedAt = time.GetUtcNow();
} }

View file

@ -70,7 +70,9 @@ public sealed record SupportCaseDto(
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset? ClosedAt, DateTimeOffset? ClosedAt,
string Source, string Source,
Guid? ClientRequestId); Guid? ClientRequestId,
string? ClosedBy = null,
string? ClosedComment = null);
/// <summary>Who created a support case.</summary> /// <summary>Who created a support case.</summary>
public static class SupportCaseSources public static class SupportCaseSources
@ -81,3 +83,15 @@ public static class SupportCaseSources
/// <summary>The customer, in SQLInsight Web.</summary> /// <summary>The customer, in SQLInsight Web.</summary>
public const string Customer = "Customer"; public const string Customer = "Customer";
} }
/// <summary>
/// The customer closes a case (POST /api/v1/cases/{reference}/close), from SQLInsight Web or its API. Closing a closed
/// case changes nothing.
/// </summary>
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;
}

View file

@ -11,6 +11,7 @@ namespace SQLInsight.Hub.Api;
/// Support cases for paying customers, through their agent: /// Support cases for paying customers, through their agent:
/// POST /api/v1/cases creates a case (idempotent on ClientRequestId) and returns its number; /// 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. /// 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).
/// </summary> /// </summary>
public static class CasesEndpoint public static class CasesEndpoint
{ {
@ -20,6 +21,7 @@ public static class CasesEndpoint
{ {
app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous(); app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous();
app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous(); app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous();
app.MapPost("/api/v1/cases/{reference}/close", CloseAsync).DisableAntiforgery().AllowAnonymous();
} }
internal static async Task<IResult> CreateAsync( internal static async Task<IResult> CreateAsync(
@ -138,5 +140,57 @@ public static class CasesEndpoint
return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList()); return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList());
} }
internal static async Task<IResult> CloseAsync(
string reference, HttpRequest request, HubDbContext db, IOptions<HubServerOptions> 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<SupportCaseCloseRequest>(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(); private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
} }

View file

@ -104,6 +104,12 @@ public sealed class SupportCase
public bool IsOpen { get; set; } = true; public bool IsOpen { get; set; } = true;
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? ClosedAt { get; set; } public DateTime? ClosedAt { get; set; }
/// <summary>Who closed it: "Kunde: DOMÆNE\bruger" (from SQLInsight Web or its API) or "SEOSoft: navn" (the portal).</summary>
public string? ClosedBy { get; set; }
/// <summary>The comment given when the case was closed.</summary>
public string? ClosedComment { get; set; }
public List<HubFinding> Findings { get; } = []; public List<HubFinding> Findings { get; } = [];
/// <summary>Who created it: <see cref="Contracts.SupportCaseSources.Staff"/> or <see cref="Contracts.SupportCaseSources.Customer"/>.</summary> /// <summary>Who created it: <see cref="Contracts.SupportCaseSources.Staff"/> or <see cref="Contracts.SupportCaseSources.Customer"/>.</summary>

View file

@ -0,0 +1,594 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("evidence_json");
b.Property<string>("Explanation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("explanation");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("Recommendation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("recommendation");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("RuleId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rule_id");
b.Property<int>("Severity")
.HasColumnType("integer")
.HasColumnName("severity");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<int?>("SupportCaseId")
.HasColumnType("integer")
.HasColumnName("support_case_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AgentVersion")
.HasColumnType("text")
.HasColumnName("agent_version");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<string>("Edition")
.HasColumnType("text")
.HasColumnName("edition");
b.Property<string>("InstanceKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("instance_key");
b.Property<DateTime?>("LastCollectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_collected_at");
b.Property<DateTime?>("LastSubmissionAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_submission_at");
b.Property<string>("ProductLevel")
.HasColumnType("text")
.HasColumnName("product_level");
b.Property<string>("ProductUpdateLevel")
.HasColumnType("text")
.HasColumnName("product_update_level");
b.Property<string>("ProductVersion")
.HasColumnType("text")
.HasColumnName("product_version");
b.Property<string>("Release")
.HasColumnType("text")
.HasColumnName("release");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("ChangedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("changed_at");
b.Property<string>("FromVersion")
.IsRequired()
.HasColumnType("text")
.HasColumnName("from_version");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<string>("ToLevel")
.HasColumnType("text")
.HasColumnName("to_level");
b.Property<string>("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<string>("LicenseId")
.HasColumnType("text")
.HasColumnName("license_id");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("issued_at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<int>("MaxInstances")
.HasColumnType("integer")
.HasColumnName("max_instances");
b.Property<string>("Plan")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("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<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<string>("RuleId")
.HasColumnType("text")
.HasColumnName("rule_id");
b.Property<string>("Outcome")
.IsRequired()
.HasColumnType("text")
.HasColumnName("outcome");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password_hash");
b.Property<string>("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<Guid>("SubmissionId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("submission_id");
b.Property<bool>("Applied")
.HasColumnType("boolean")
.HasColumnName("applied");
b.Property<DateTime>("CollectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("collected_at");
b.Property<int>("FindingCount")
.HasColumnType("integer")
.HasColumnName("finding_count");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("ClientRequestId")
.HasColumnType("uuid")
.HasColumnName("client_request_id");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("closed_at");
b.Property<string>("ClosedBy")
.HasColumnType("text")
.HasColumnName("closed_by");
b.Property<string>("ClosedComment")
.HasColumnType("text")
.HasColumnName("closed_comment");
b.Property<string>("ContactEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")
.HasColumnName("contact_email");
b.Property<string>("ContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("contact_name");
b.Property<string>("ContactPhone")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("contact_phone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<string>("Description")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)")
.HasColumnName("description");
b.Property<bool>("IsOpen")
.HasColumnType("boolean")
.HasColumnName("is_open");
b.Property<string>("Note")
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Reference")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("RequestedBy")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("requested_by");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasDefaultValue("Staff")
.HasColumnName("source");
b.Property<string>("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
}
}
}

View file

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SQLInsight.Hub.Data.Migrations
{
/// <inheritdoc />
public partial class CaseClosedBy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "closed_by",
table: "support_cases",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "closed_comment",
table: "support_cases",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "closed_by",
table: "support_cases");
migrationBuilder.DropColumn(
name: "closed_comment",
table: "support_cases");
}
}
}

View file

@ -414,6 +414,14 @@ namespace SQLInsight.Hub.Data.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("closed_at"); .HasColumnName("closed_at");
b.Property<string>("ClosedBy")
.HasColumnType("text")
.HasColumnName("closed_by");
b.Property<string>("ClosedComment")
.HasColumnType("text")
.HasColumnName("closed_comment");
b.Property<string>("ContactEmail") b.Property<string>("ContactEmail")
.HasMaxLength(320) .HasMaxLength(320)
.HasColumnType("character varying(320)") .HasColumnType("character varying(320)")

View file

@ -70,7 +70,7 @@
else else
{ {
<table> <table>
<thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead> <thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th><th>Lukket af</th></tr></thead>
<tbody> <tbody>
@foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt)) @foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
{ {
@ -81,6 +81,7 @@
<td>@(c.IsOpen ? "Åben" : "Lukket")</td> <td>@(c.IsOpen ? "Åben" : "Lukket")</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td> <td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td> <td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
<td>@c.ClosedBy@(c.ClosedComment is { } comment ? $": {comment}" : "")</td>
</tr> </tr>
} }
</tbody> </tbody>

View file

@ -82,7 +82,7 @@
else else
{ {
<table> <table>
<thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th>Kilde</th><th>Oprettet</th><th>Lukket</th><th>Løsningstid</th></tr></thead> <thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th>Kilde</th><th>Oprettet</th><th>Lukket</th><th>Lukket af</th><th>Løsningstid</th></tr></thead>
<tbody> <tbody>
@foreach (var c in _report.Cases) @foreach (var c in _report.Cases)
{ {
@ -93,6 +93,7 @@
<td>@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")</td> <td>@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td> <td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td> <td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td title="@c.ClosedBy">@c.ClosedByWhom</td>
<td class="nowrap">@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))</td> <td class="nowrap">@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))</td>
</tr> </tr>
} }

View file

@ -41,7 +41,7 @@ public static class PortalEndpoints
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return Results.Redirect($"/portal/fund/{id}"); 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); var supportCase = await db.SupportCases.FindAsync([id], ct);
if (supportCase is null) if (supportCase is null)
@ -49,8 +49,13 @@ public static class PortalEndpoints
return Results.NotFound(); return Results.NotFound();
} }
supportCase.IsOpen = false; if (supportCase.IsOpen)
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime; {
supportCase.IsOpen = false;
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
supportCase.ClosedBy = "SEOSoft: " + (http.User.Identity?.Name ?? "ukendt");
}
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return Results.Redirect("/portal/sager"); return Results.Redirect("/portal/sager");
}); });

View file

@ -110,11 +110,12 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time)
var cases = await db.SupportCases var cases = await db.SupportCases
.Where(c => customerId == null || c.CustomerId == customerId) .Where(c => customerId == null || c.CustomerId == customerId)
.Where(c => c.IsOpen || (c.CreatedAt >= from && c.CreatedAt < to) || (c.ClosedAt >= from && c.ClosedAt < to)) .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); .ToListAsync(ct).ConfigureAwait(false);
// A closed case without a closing time (closed before it was recorded) counts as closed when it was created. // A closed case without a closing time (closed before it was recorded) counts as closed when it was created.
return Support.SupportReport.Build( 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); from, to, Now);
} }

View file

@ -67,7 +67,8 @@ public static class SupportCaseStore
public static SupportCaseDto ToDto(SupportCase c) public static SupportCaseDto ToDto(SupportCase c)
{ {
ArgumentNullException.ThrowIfNull(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)); private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));

View file

@ -5,9 +5,14 @@ using SQLInsight.Contracts;
namespace SQLInsight.Hub.Support; namespace SQLInsight.Hub.Support;
/// <summary>A support case as the report needs it.</summary> /// <summary>A support case as the report needs it.</summary>
public sealed record SupportReportCase(int Id, int CustomerId, string Customer, string Reference, string Title, string Source, DateTime CreatedAt, DateTime? ClosedAt) /// <param name="ClosedBy">"Kunde: …" or "SEOSoft: …"; null for open cases and cases closed before it was recorded.</param>
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; public TimeSpan? ResolutionTime => ClosedAt - CreatedAt;
/// <summary>"Kunde" when the customer closed it, "SEOSoft" when staff did, empty while open.</summary>
public string ClosedByWhom => ClosedAt is null ? string.Empty : ClosedBy?.StartsWith("Kunde", StringComparison.Ordinal) == true ? "Kunde" : "SEOSoft";
} }
/// <summary>Key figures for one customer (or, for the total row, all customers).</summary> /// <summary>Key figures for one customer (or, for the total row, all customers).</summary>
@ -127,7 +132,7 @@ public static class SupportReport
{ {
ArgumentNullException.ThrowIfNull(report); ArgumentNullException.ThrowIfNull(report);
var csv = new StringBuilder(); 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) foreach (var c in report.Cases)
{ {
csv.AppendJoin(';', csv.AppendJoin(';',
@ -137,6 +142,7 @@ public static class SupportReport
Field(c.Source == SupportCaseSources.Customer ? "Kunde" : "SEOSoft"), Field(c.Source == SupportCaseSources.Customer ? "Kunde" : "SEOSoft"),
c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty, c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
Field(c.ClosedByWhom),
c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty); c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty);
csv.Append("\r\n"); csv.Append("\r\n");
} }

View file

@ -9,6 +9,9 @@ public static class SupportCaseStatus
public const string Pending = "Pending"; public const string Pending = "Pending";
public const string Open = "Open"; public const string Open = "Open";
/// <summary>Closing was requested in SQLInsight Web; the agent has not had it confirmed by the hub yet.</summary>
public const string CloseRequested = "CloseRequested";
public const string Closed = "Closed"; public const string Closed = "Closed";
/// <summary>The hub refused the request (e.g. no support in the license); see LastError.</summary> /// <summary>The hub refused the request (e.g. no support in the license); see LastError.</summary>
@ -37,6 +40,11 @@ public sealed record SupportCaseRow
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public DateTime? SentAt { get; init; } public DateTime? SentAt { get; init; }
public DateTime? ClosedAt { 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; }
} }
/// <summary>A request filed in SQLInsight Web.</summary> /// <summary>A request filed in SQLInsight Web.</summary>
@ -54,7 +62,8 @@ public sealed record NewSupportRequest(
string? Subject = null); string? Subject = null);
/// <summary>A case as the hub reports it.</summary> /// <summary>A case as the hub reports it.</summary>
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 public sealed partial class InsightRepository
{ {
@ -102,7 +111,8 @@ public sealed partial class InsightRepository
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync(""" await connection.ExecuteAsync("""
UPDATE insight.SupportCases 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 SentAt = @now, Attempts = Attempts + 1, LastError = NULL, UpdatedAt = @now
WHERE CaseId = @caseId; WHERE CaseId = @caseId;
""", new { caseId, reference, isOpen, closedAt, now = Utc(now) }).ConfigureAwait(false); """, new { caseId, reference, isOpen, closedAt, now = Utc(now) }).ConfigureAwait(false);
@ -132,13 +142,15 @@ public sealed partial class InsightRepository
{ {
await connection.ExecuteAsync(""" await connection.ExecuteAsync("""
UPDATE insight.SupportCases 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 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; WHERE (@ClientRequestId IS NOT NULL AND ClientRequestId = @ClientRequestId) OR Reference = @Reference;
IF @@ROWCOUNT = 0 IF @@ROWCOUNT = 0
INSERT insight.SupportCases (ClientRequestId, Reference, Title, Source, Status, CreatedAt, ClosedAt, SentAt, UpdatedAt) 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); 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);
} }
} }
} }

View file

@ -0,0 +1,120 @@
using Dapper;
namespace SQLInsight.Repository;
public enum SupportCloseOutcome
{
NotFound,
/// <summary>Closed already: nothing to do.</summary>
AlreadyClosed,
/// <summary>Recorded; the agent sends it (at once, or once the hub has created the case).</summary>
Requested,
/// <summary>The hub refused the case; it cannot be closed.</summary>
Failed,
}
public sealed partial class InsightRepository
{
public async Task<SupportCaseRow?> GetSupportCaseAsync(long caseId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SupportCaseRow>("SELECT * FROM insight.SupportCases WHERE CaseId = @caseId;", new { caseId })
.ConfigureAwait(false);
}
public async Task<SupportCaseRow?> GetSupportCaseByRequestIdAsync(Guid clientRequestId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SupportCaseRow>(
"SELECT * FROM insight.SupportCases WHERE ClientRequestId = @clientRequestId;", new { clientRequestId }).ConfigureAwait(false);
}
/// <summary>The customer's cases, newest first: optionally by status and created in [from, to]; one page and whether there are more.</summary>
public async Task<(IReadOnlyList<SupportCaseRow> 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<SupportCaseRow>("""
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);
}
/// <summary>
/// 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.
/// </summary>
public async Task<SupportCloseOutcome> 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<string>("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];
}
/// <summary>Close requests the agent should send now: cases with a number whose closing is not confirmed.</summary>
public async Task<IReadOnlyList<SupportCaseRow>> GetDueSupportClosesAsync(DateTimeOffset now, int max = 10, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SupportCaseRow>("""
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();
}
/// <summary>The hub confirmed the closing.</summary>
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);
}
/// <summary>
/// A failed attempt to close. With <paramref name="nextAttemptAt"/> null the hub refused for good: the case is open
/// again with the reason in LastError.
/// </summary>
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);
}
}

View file

@ -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;

View file

@ -0,0 +1,137 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>Body of POST /api/v1/support-cases.</summary>
public sealed record SupportCaseCreateBody(string? Title, string? Description, string? ContactName, string? ContactEmail, string? ContactPhone, long? FindingId);
/// <summary>Body of POST /api/v1/support-cases/{id}/close.</summary>
public sealed record SupportCaseCloseBody(string? Comment);
/// <summary>
/// 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.
/// </summary>
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<IResult> 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));
}
/// <summary>An Idempotency-Key as the request id: a GUID as it is, anything else hashed to one.</summary>
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<IResult> 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<IResult> 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<IResult> 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,
};
}

View file

@ -6,12 +6,16 @@
<PageTitle>Support · SQLInsight Pro</PageTitle> <PageTitle>Support · SQLInsight Pro</PageTitle>
<h1>Support</h1> <h1>Support</h1>
<p class="lead">Supportsager hos SEOSoft. Sagen sendes af agenten til SQLInsight Hub, som giver den et sagsnummer. Status opdateres her, når SEOSoft lukker sagen.</p> <p class="lead">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.</p>
@if (Sendt == 1) @if (Sendt == 1)
{ {
<p class="notice ok" role="status">Sagen er sendt. Den får et sagsnummer, så snart agenten har været i kontakt med SQLInsight Hub (normalt inden for et minut).</p> <p class="notice ok" role="status">Sagen er sendt. Den får et sagsnummer, så snart agenten har været i kontakt med SQLInsight Hub (normalt inden for et minut).</p>
} }
@if (Lukket == 1)
{
<p class="notice ok" role="status">Lukningen er sendt. Sagen står som lukket, når SQLInsight Hub har bekræftet den (normalt inden for et minut).</p>
}
@if (!string.IsNullOrEmpty(Fejl)) @if (!string.IsNullOrEmpty(Fejl))
{ {
<p class="notice bad" role="alert">@Fejl</p> <p class="notice bad" role="alert">@Fejl</p>
@ -58,7 +62,7 @@ else
else else
{ {
<table> <table>
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead> <thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th><th>Lukket</th><th></th></tr></thead>
<tbody> <tbody>
@foreach (var c in _cases) @foreach (var c in _cases)
{ {
@ -68,6 +72,20 @@ else
<td>@Support.StatusText(c)@if (c.Status == SupportCaseStatus.Failed && c.LastError is { } error) { <div class="muted">@error</div> }</td> <td>@Support.StatusText(c)@if (c.Status == SupportCaseStatus.Failed && c.LastError is { } error) { <div class="muted">@error</div> }</td>
<td class="nowrap">@Display.Time(c.CreatedAt)</td> <td class="nowrap">@Display.Time(c.CreatedAt)</td>
<td class="nowrap">@Display.Time(c.ClosedAt)</td> <td class="nowrap">@Display.Time(c.ClosedAt)</td>
<td>
@if (c.Status is SupportCaseStatus.Open or SupportCaseStatus.Pending && c.CloseRequestedAt is null)
{
<form method="post" action="/support/@c.CaseId/luk" class="inline-form">
<AntiforgeryToken />
<input type="text" name="comment" maxlength="1000" placeholder="Kommentar (valgfri)" aria-label="Kommentar til lukning" />
<button type="submit" class="button">Luk sag</button>
</form>
}
else if (c.ClosedBy is { } by)
{
<span class="muted">@by</span>
}
</td>
</tr> </tr>
} }
</tbody> </tbody>
@ -79,6 +97,7 @@ else
[CascadingParameter] private HttpContext? Http { get; set; } [CascadingParameter] private HttpContext? Http { get; set; }
[SupplyParameterFromQuery(Name = "fund")] public long? Fund { get; set; } [SupplyParameterFromQuery(Name = "fund")] public long? Fund { get; set; }
[SupplyParameterFromQuery(Name = "sendt")] public int? Sendt { 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; } [SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
private HubStatusRow? _hub; private HubStatusRow? _hub;

View file

@ -115,6 +115,7 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit
IntegrationEndpoints.Map(app); IntegrationEndpoints.Map(app);
ApiV1Endpoints.Map(app); ApiV1Endpoints.Map(app);
SupportApiEndpoints.Map(app);
app.MapOpenApi("/api/v1/openapi.json").RequireRead(); app.MapOpenApi("/api/v1/openapi.json").RequireRead();
Support.Map(app); Support.Map(app);

View file

@ -11,7 +11,12 @@ namespace SQLInsight.Web;
/// </summary> /// </summary>
public static class Support 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);
}
/// <summary>Title and description for a case about <paramref name="finding"/>. Sensitive measurements are left out.</summary> /// <summary>Title and description for a case about <paramref name="finding"/>. Sensitive measurements are left out.</summary>
public static (string Title, string Description) Prefill(StoredFinding finding) 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.Pending => row.Attempts > 0 ? "Venter på hubben (prøver igen)" : "Sendes",
SupportCaseStatus.Open => "Åben", SupportCaseStatus.Open => "Åben",
SupportCaseStatus.CloseRequested => "Lukning sendes",
SupportCaseStatus.Closed => "Lukket", SupportCaseStatus.Closed => "Lukket",
_ => "Afvist", _ => "Afvist",
}; };
} }
/// <summary>What filing a request came to.</summary>
/// <param name="Existing">The same request (Idempotency-Key) was filed before: this is that case.</param>
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.";
/// <summary>
/// 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.
/// </summary>
public static async Task<FileResult> 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<IResult> CreateAsync( private static async Task<IResult> CreateAsync(
HttpContext http, InsightRepository repository, HttpContext http, InsightRepository repository,
[FromForm] string? title, [FromForm] string? description, [FromForm] string? contactName, [FromForm] string? contactEmail, [FromForm] string? title, [FromForm] string? description, [FromForm] string? contactName, [FromForm] string? contactEmail,
[FromForm] string? contactPhone, [FromForm] long? findingId) [FromForm] string? contactPhone, [FromForm] long? findingId)
{ {
var back = findingId is { } id ? $"&fund={id}" : string.Empty; var back = findingId is { } id ? $"&fund={id}" : string.Empty;
if ((await repository.GetHubStatusAsync(http.RequestAborted))?.AllowsSupportCases != true) var result = await FileAsync(repository, Guid.NewGuid(), title, description, contactName, contactEmail, contactPhone, findingId,
{ http.User.Identity?.Name, http.RequestAborted);
return Results.Redirect("/support?fejl=" + Uri.EscapeDataString("Supportsager kræver Professional eller Enterprise og at deling med SQLInsight Hub er slået til.")); return result.Error is { } error
} ? Results.Redirect("/support?fejl=" + Uri.EscapeDataString(error) + (result.NotAllowed ? string.Empty : back))
: Results.Redirect("/support?sendt=1");
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");
} }
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); /// <summary>The "Luk sag" button on /support.</summary>
private static async Task<IResult> 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();
} }

View file

@ -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;
/// <summary>Support cases through the API of SQLInsight Web: created and closed, through the agent to the hub and back.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class SupportApiTests(SqlServerFixture sql, HubFixture hub) : IClassFixture<HubFixture>, 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<HttpClient> 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<HubSyncService>.Instance);
}
private WebApplicationFactory<Program> Web() => new WebApplicationFactory<Program>().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<HubDbContext>();
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<Program>().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
}
}

View file

@ -290,7 +290,7 @@ public class ApiV1Tests(SqlServerFixture sql) : IAsyncLifetime
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>() .OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
.Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/')) .Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/'))
.Where(p => p.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase) && !p.EndsWith("openapi.json", StringComparison.OrdinalIgnoreCase)) .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() .Distinct()
.ToList(); .ToList();

View file

@ -151,6 +151,12 @@ public partial class WebSupportTests(SqlServerFixture sql) : IAsyncLifetime
Assert.True(id > 0); Assert.True(id > 0);
await Assert.ThrowsAsync<SqlException>(() => readOnly.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow)); await Assert.ThrowsAsync<SqlException>(() => 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<SqlException>(() => readOnly.MarkSupportClosedAsync(id, DateTime.UtcNow, "Kunde: x", DateTimeOffset.UtcNow));
} }
[Fact] [Fact]