sqlinsight-pro/src/SQLInsight.Repository/InsightRepository.Support.cs
Søren Eggert Lundsteen Olsen 50bc878776 Customers file support cases in SQLInsight Web and follow their status
- Repository migration 0005: insight.SupportCases. The web UI has only
  db_datareader, so INSERT on this one table is granted to public.
- Web: /support (menu "Support") lists the customer's cases and, with
  Hub sync on and a Professional/Enterprise license, a form (title,
  description, contact; contact prefilled from the Windows user).
  /support?fund=<id> prefills from a finding: instance, rule, severity,
  explanation and non-sensitive measurements.
- "Opret supportsag" on findings: right-click a finding link or a
  finding's heading (support-menu.js, a static file within the CSP;
  Shift+F10/context key, Escape closes), and a visible button on the
  finding page that works without JavaScript. Only when support is
  available.
- Agent: sends pending requests to POST /api/v1/cases, stores the case
  number, backs off on failures and gives up on 400/401/403/422; fetches
  the customer's cases every 5 minutes (at once after sending).
- Docs: support.md (also on the community site), databehandling.md
  (contact details are sent to the hub).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:34:06 +02:00

144 lines
7.7 KiB
C#

using Dapper;
namespace SQLInsight.Repository;
/// <summary>Values of insight.SupportCases.Status.</summary>
public static class SupportCaseStatus
{
/// <summary>Filed in SQLInsight Web; the agent has not had a case number back from the hub yet.</summary>
public const string Pending = "Pending";
public const string Open = "Open";
public const string Closed = "Closed";
/// <summary>The hub refused the request (e.g. no support in the license); see LastError.</summary>
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; }
}
/// <summary>A request filed in SQLInsight Web.</summary>
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);
/// <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 partial class InsightRepository
{
/// <summary>Files a support request (SQLInsight Web: needs only INSERT on this table, see 0005_support_cases.sql).</summary>
public async Task<long> AddSupportRequestAsync(NewSupportRequest request, DateTimeOffset now, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<long>("""
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);
}
/// <summary>The customer's support cases, newest first.</summary>
public async Task<IReadOnlyList<SupportCaseRow>> GetSupportCasesAsync(int max = 200, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SupportCaseRow>(
"SELECT TOP (@max) * FROM insight.SupportCases ORDER BY CreatedAt DESC, CaseId DESC;", new { max }).ConfigureAwait(false);
return rows.ToList();
}
public async Task<IReadOnlyList<SupportCaseRow>> GetDueSupportRequestsAsync(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 = 'Pending' AND ClientRequestId IS NOT NULL AND NextAttemptAt <= @now
ORDER BY NextAttemptAt;
""", new { max, now = Utc(now) }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>The hub created (or already had) the case: store its number.</summary>
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);
}
/// <summary>A failed attempt. With <paramref name="nextAttemptAt"/> null the request is given up (Status Failed).</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public async Task SyncSupportCasesAsync(IEnumerable<HubSupportCase> 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);
}
}
}