diff --git a/docs/databehandling.md b/docs/databehandling.md
index 83a052a..62752a7 100644
--- a/docs/databehandling.md
+++ b/docs/databehandling.md
@@ -27,6 +27,8 @@ Kun når `SQLInsight:Hub:Enabled` er `true` og der er en gyldig licens. Agenten
Der sendes aldrig tabeldata, forespørgselsresultater, adgangskoder eller forbindelsesstrenge.
+**Supportsager** (Professional og Enterprise, se [support.md](support.md)): når en bruger opretter en sag i webbrugerfladen, sendes titel, beskrivelse, kontaktpersonens navn, e-mail og eventuelt telefonnummer samt brugerens Windows-brugernavn til hubben. Det er personoplysninger, som SEOSoft bruger til at besvare sagen. Beskrivelsen indeholder kun det, brugeren selv skriver; følsomme målinger sættes ikke automatisk ind.
+
Kunden kan se præcis det, der er sendt, under **System** i webbrugerfladen ("Det sendes til SQLInsight Hub").
Slår kunden `SendSensitiveEvidence` til, kan fund indeholde personoplysninger (brugernavne, og data i forespørgselstekst som fx `WHERE cpr = '…'`). Det bør kun ske efter aftale, og det skal fremgå af databehandleraftalen.
diff --git a/docs/support.md b/docs/support.md
new file mode 100644
index 0000000..0be2a22
--- /dev/null
+++ b/docs/support.md
@@ -0,0 +1,25 @@
+# Supportsager
+
+Kunder med Professional eller Enterprise kan oprette supportsager hos SEOSoft direkte fra SQLInsight Web og følge deres status.
+
+## Krav
+
+- En gyldig Professional- eller Enterprise-licens og deling med SQLInsight Hub slået til (`SQLInsight:Hub:Enabled`, se [installation.md](installation.md)). Under **System** i webbrugerfladen kan I se, om licensen er gyldig.
+- Uden det viser siden **Support** en forklaring i stedet for formularen, og der er ingen "Opret supportsag" på fundene.
+
+## Opret en sag
+
+- **Support** i menuen: titel, beskrivelse, kontaktperson, e-mail og eventuelt telefon. Kontaktpersonen er udfyldt med jeres Windows-brugernavn og kan rettes.
+- **Fra et fund:** højreklik på et fund i en fundliste eller på fundets overskrift, og vælg **Opret supportsag** (tastatur: Shift+F10 eller kontekstmenu-tasten på et markeret fund; Escape lukker). På fundets side er der også en knap. Titlen og beskrivelsen udfyldes med fundets instans, regel, alvor, forklaring og målinger. Følsomme målinger (forespørgselstekst, logins, servernavne) sættes ikke ind.
+
+Sagen sendes af agenten, ikke af webbrugerfladen: den gemmes i repository-databasen, og agenten sender den til SQLInsight Hub inden for et halvt minut. Hubben giver den et sagsnummer (`SUP-000001`, `SUP-000002` …), som vises på siden **Support**. Kan hubben ikke nås, prøver agenten igen. Afviser hubben sagen (fx fordi licensen ikke dækker support), står den som *Afvist* med årsagen.
+
+## Status
+
+Siden **Support** viser alle jeres sager, også dem SEOSoft har oprettet for jer, med status *Åben* eller *Lukket*. Agenten henter status fra hubben hvert 5. minut. Dialogen om sagen foregår på mail eller telefon.
+
+## Rettigheder
+
+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).
diff --git a/src/SQLInsight.Agent/Hub/HubSyncService.cs b/src/SQLInsight.Agent/Hub/HubSyncService.cs
index 73f333b..5e01f07 100644
--- a/src/SQLInsight.Agent/Hub/HubSyncService.cs
+++ b/src/SQLInsight.Agent/Hub/HubSyncService.cs
@@ -108,9 +108,89 @@ public sealed partial class HubSyncService(
await FetchInsightsAsync(http, settings, cancellationToken).ConfigureAwait(false);
}
+ if (status.License?.IncludesPrioritySupport == true)
+ {
+ await SyncSupportCasesAsync(http, settings, cancellationToken).ConfigureAwait(false);
+ }
+
return sent;
}
+ /// How often the status of the customer's support cases is fetched when nothing was sent.
+ public static readonly TimeSpan CasesInterval = TimeSpan.FromMinutes(5);
+
+ private DateTimeOffset? _casesFetchedAt;
+
+ ///
+ /// Sends support requests filed in SQLInsight Web and stores the case number the hub gives them; then fetches the
+ /// status of all the customer's cases. Rejections the hub will keep making are given up, like submissions.
+ ///
+ public async Task SyncSupportCasesAsync(HttpClient http, HubOptions settings, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(http);
+ ArgumentNullException.ThrowIfNull(settings);
+ var created = 0;
+ foreach (var item in await repository.GetDueSupportRequestsAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false))
+ {
+ var now = time.GetUtcNow();
+ try
+ {
+ var body = new Contracts.SupportCaseRequest(item.ClientRequestId!.Value, item.Title, item.Description, item.ContactName ?? string.Empty,
+ item.ContactEmail ?? string.Empty, item.ContactPhone, item.RequestedBy, item.InstanceName, item.RuleId, item.Subject);
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Url!, "api/v1/cases"))
+ {
+ 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.MarkSupportRequestSentAsync(item.CaseId, dto.Reference, dto.IsOpen, dto.ClosedAt?.UtcDateTime, 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.UnprocessableEntity;
+ await repository.MarkSupportRequestFailedAsync(item.CaseId, error, now, permanent ? null : now + Backoff(item.Attempts), 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.MarkSupportRequestFailedAsync(item.CaseId, ex.Message, now, now + Backoff(item.Attempts), cancellationToken).ConfigureAwait(false);
+ LogCaseUnreachable(logger, ex.Message);
+ }
+ }
+
+ if (created == 0 && _casesFetchedAt is { } fetched && time.GetUtcNow() - fetched < CasesInterval)
+ {
+ return;
+ }
+
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(settings.Url!, "api/v1/cases"));
+ request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License);
+ using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ return;
+ }
+
+ 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)),
+ time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
+ _casesFetchedAt = time.GetUtcNow();
+ }
+ catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
+ {
+ LogCaseUnreachable(logger, ex.Message);
+ }
+ }
+
/// How often aggregated experience from other customers is fetched.
public static readonly TimeSpan InsightsInterval = TimeSpan.FromHours(6);
@@ -166,6 +246,12 @@ public sealed partial class HubSyncService(
[LoggerMessage(Level = LogLevel.Information, Message = "Could not fetch insights from the hub: {Message}")]
private static partial void LogInsightsFailed(ILogger logger, string message);
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Hub rejected the support case {Title}: {Error}")]
+ private static partial void LogCaseRejected(ILogger logger, string title, string error);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Support cases could not be synced with the hub: {Message}")]
+ private static partial void LogCaseUnreachable(ILogger logger, string message);
+
[LoggerMessage(Level = LogLevel.Error, Message = "Hub sync round failed")]
private static partial void LogRoundFailed(ILogger logger, Exception exception);
}
diff --git a/src/SQLInsight.Community/Services/DocsService.cs b/src/SQLInsight.Community/Services/DocsService.cs
index 416b8be..704cfe8 100644
--- a/src/SQLInsight.Community/Services/DocsService.cs
+++ b/src/SQLInsight.Community/Services/DocsService.cs
@@ -23,6 +23,7 @@ public sealed class DocsService
("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."),
("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."),
("regler", "Skriv egne regler i SQL, og bidrag med community-regler."),
+ ("support", "Opret supportsager hos SEOSoft fra webbrugerfladen og følg deres status."),
("databehandling", "Hvilke data der behandles, hvor de ligger, og hvad der deles."),
("legacy-versions", "SQL Server 2000, 2005 og 2008: hvad der kan måles, og hvordan der forbindes."),
];
diff --git a/src/SQLInsight.Repository/InsightRepository.Hub.cs b/src/SQLInsight.Repository/InsightRepository.Hub.cs
index 23dcbeb..093325f 100644
--- a/src/SQLInsight.Repository/InsightRepository.Hub.cs
+++ b/src/SQLInsight.Repository/InsightRepository.Hub.cs
@@ -19,6 +19,13 @@ public sealed record HubStatusRow
public DateTime? LastErrorAt { get; init; }
public DateTime UpdatedAt { get; init; }
public int Pending { get; init; }
+
+ ///
+ /// Support cases can be filed: Hub sync is on and the agent last saw a valid (or grace-period) Professional or
+ /// Enterprise license. The agent is what talks to the hub, so its view decides.
+ ///
+ public bool AllowsSupportCases =>
+ Enabled && LicenseState is "Valid" or "ExpiredInGrace" && LicensePlan is "Professional" or "Enterprise";
}
public sealed partial class InsightRepository
diff --git a/src/SQLInsight.Repository/InsightRepository.Support.cs b/src/SQLInsight.Repository/InsightRepository.Support.cs
new file mode 100644
index 0000000..8fffa14
--- /dev/null
+++ b/src/SQLInsight.Repository/InsightRepository.Support.cs
@@ -0,0 +1,144 @@
+using Dapper;
+
+namespace SQLInsight.Repository;
+
+/// Values of insight.SupportCases.Status.
+public static class SupportCaseStatus
+{
+ /// Filed in SQLInsight Web; the agent has not had a case number back from the hub yet.
+ public const string Pending = "Pending";
+
+ public const string Open = "Open";
+ public const string Closed = "Closed";
+
+ /// The hub refused the request (e.g. no support in the license); see LastError.
+ public const string Failed = "Failed";
+}
+
+public sealed record SupportCaseRow
+{
+ public long CaseId { get; init; }
+ public Guid? ClientRequestId { get; init; }
+ public string? Reference { get; init; }
+ public string Title { get; init; } = string.Empty;
+ public string? Description { get; init; }
+ public string? ContactName { get; init; }
+ public string? ContactEmail { get; init; }
+ public string? ContactPhone { get; init; }
+ public long? FindingId { get; init; }
+ public string? InstanceName { get; init; }
+ public string? RuleId { get; init; }
+ public string? Subject { get; init; }
+ public string? RequestedBy { get; init; }
+ public string Source { get; init; } = "Customer";
+ public string Status { get; init; } = SupportCaseStatus.Pending;
+ public string? LastError { get; init; }
+ public int Attempts { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public DateTime? SentAt { get; init; }
+ public DateTime? ClosedAt { get; init; }
+}
+
+/// A request filed in SQLInsight Web.
+public sealed record NewSupportRequest(
+ Guid ClientRequestId,
+ string Title,
+ string? Description,
+ string ContactName,
+ string ContactEmail,
+ string? ContactPhone,
+ string? RequestedBy,
+ long? FindingId = null,
+ string? InstanceName = null,
+ string? RuleId = null,
+ 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 partial class InsightRepository
+{
+ /// Files a support request (SQLInsight Web: needs only INSERT on this table, see 0005_support_cases.sql).
+ public async Task AddSupportRequestAsync(NewSupportRequest request, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ return await connection.ExecuteScalarAsync("""
+ INSERT insight.SupportCases (ClientRequestId, Title, Description, ContactName, ContactEmail, ContactPhone, RequestedBy,
+ FindingId, InstanceName, RuleId, Subject, Source, Status, NextAttemptAt, CreatedAt, UpdatedAt)
+ OUTPUT INSERTED.CaseId
+ VALUES (@ClientRequestId, @Title, @Description, @ContactName, @ContactEmail, @ContactPhone, @RequestedBy,
+ @FindingId, @InstanceName, @RuleId, @Subject, 'Customer', 'Pending', @now, @now, @now);
+ """, new
+ {
+ request.ClientRequestId, request.Title, request.Description, request.ContactName, request.ContactEmail, request.ContactPhone,
+ request.RequestedBy, request.FindingId, request.InstanceName, request.RuleId, request.Subject, now = Utc(now),
+ }).ConfigureAwait(false);
+ }
+
+ /// The customer's support cases, newest first.
+ public async Task> GetSupportCasesAsync(int max = 200, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync(
+ "SELECT TOP (@max) * FROM insight.SupportCases ORDER BY CreatedAt DESC, CaseId DESC;", new { max }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ public async Task> GetDueSupportRequestsAsync(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 = 'Pending' AND ClientRequestId IS NOT NULL AND NextAttemptAt <= @now
+ ORDER BY NextAttemptAt;
+ """, new { max, now = Utc(now) }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ /// The hub created (or already had) the case: store its number.
+ public async Task MarkSupportRequestSentAsync(long caseId, string reference, bool isOpen, DateTime? closedAt, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ 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,
+ SentAt = @now, Attempts = Attempts + 1, LastError = NULL, UpdatedAt = @now
+ WHERE CaseId = @caseId;
+ """, new { caseId, reference, isOpen, closedAt, now = Utc(now) }).ConfigureAwait(false);
+ }
+
+ /// A failed attempt. With null the request is given up (Status Failed).
+ public async Task MarkSupportRequestFailedAsync(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 Attempts = Attempts + 1, LastError = LEFT(@error, 1000), UpdatedAt = @now,
+ NextAttemptAt = COALESCE(@next, NextAttemptAt), Status = CASE WHEN @next IS NULL THEN 'Failed' ELSE Status END
+ WHERE CaseId = @caseId;
+ """, new { caseId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false);
+ }
+
+ ///
+ /// Brings the local list in step with the hub: status and number of the customer's own requests, and the cases
+ /// SEOSoft created for the customer.
+ ///
+ public async Task SyncSupportCasesAsync(IEnumerable cases, DateTimeOffset now, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(cases);
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ foreach (var c in cases)
+ {
+ await connection.ExecuteAsync("""
+ UPDATE insight.SupportCases
+ SET Reference = @Reference, Status = CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @ClosedAt,
+ 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);
+ }
+ }
+}
diff --git a/src/SQLInsight.Repository/Migrations/0005_support_cases.sql b/src/SQLInsight.Repository/Migrations/0005_support_cases.sql
new file mode 100644
index 0000000..c77d019
--- /dev/null
+++ b/src/SQLInsight.Repository/Migrations/0005_support_cases.sql
@@ -0,0 +1,36 @@
+/* Support cases with SEOSoft (Professional/Enterprise). SQLInsight Web adds a request (Status 'Pending'); the agent
+ sends it to SQLInsight Hub, stores the case number it gets back, and keeps every case's status in step with the hub,
+ including cases SEOSoft created (ClientRequestId NULL). */
+CREATE TABLE insight.SupportCases
+(
+ CaseId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SupportCases PRIMARY KEY,
+ ClientRequestId uniqueidentifier NULL,
+ Reference nvarchar(32) NULL,
+ Title nvarchar(200) NOT NULL,
+ Description nvarchar(max) NULL,
+ ContactName nvarchar(200) NULL,
+ ContactEmail nvarchar(320) NULL,
+ ContactPhone nvarchar(50) NULL,
+ FindingId bigint NULL,
+ InstanceName nvarchar(128) NULL,
+ RuleId nvarchar(50) NULL,
+ Subject nvarchar(400) NULL,
+ RequestedBy nvarchar(256) NULL,
+ Source varchar(16) NOT NULL CONSTRAINT DF_SupportCases_Source DEFAULT ('Customer'),
+ Status varchar(16) NOT NULL CONSTRAINT DF_SupportCases_Status DEFAULT ('Pending')
+ CONSTRAINT CK_SupportCases_Status CHECK (Status IN ('Pending', 'Open', 'Closed', 'Failed')),
+ LastError nvarchar(1000) NULL,
+ Attempts int NOT NULL CONSTRAINT DF_SupportCases_Attempts DEFAULT (0),
+ NextAttemptAt datetime2(0) NOT NULL CONSTRAINT DF_SupportCases_NextAttemptAt DEFAULT (SYSUTCDATETIME()),
+ CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_SupportCases_CreatedAt DEFAULT (SYSUTCDATETIME()),
+ SentAt datetime2(0) NULL,
+ ClosedAt datetime2(0) NULL,
+ UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_SupportCases_UpdatedAt DEFAULT (SYSUTCDATETIME())
+);
+CREATE UNIQUE INDEX UQ_SupportCases_ClientRequestId ON insight.SupportCases (ClientRequestId) WHERE ClientRequestId IS NOT NULL;
+CREATE UNIQUE INDEX UQ_SupportCases_Reference ON insight.SupportCases (Reference) WHERE Reference IS NOT NULL;
+CREATE INDEX IX_SupportCases_Pending ON insight.SupportCases (NextAttemptAt) WHERE Status = 'Pending';
+
+/* The web UI only has db_datareader, yet it must be able to file a request. INSERT on this one table is granted to
+ public: only the repository's own users (the agent, the web UI and its administrators) exist in this database. */
+GRANT INSERT ON insight.SupportCases TO public;
diff --git a/src/SQLInsight.Web/Components/App.razor b/src/SQLInsight.Web/Components/App.razor
index d3ef523..aa931f2 100644
--- a/src/SQLInsight.Web/Components/App.razor
+++ b/src/SQLInsight.Web/Components/App.razor
@@ -14,5 +14,6 @@
+