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>
This commit is contained in:
parent
83c247d536
commit
50bc878776
17 changed files with 862 additions and 1 deletions
|
|
@ -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.
|
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").
|
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.
|
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.
|
||||||
|
|
|
||||||
25
docs/support.md
Normal file
25
docs/support.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -108,9 +108,89 @@ public sealed partial class HubSyncService(
|
||||||
await FetchInsightsAsync(http, settings, cancellationToken).ConfigureAwait(false);
|
await FetchInsightsAsync(http, settings, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.License?.IncludesPrioritySupport == true)
|
||||||
|
{
|
||||||
|
await SyncSupportCasesAsync(http, settings, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>How often the status of the customer's support cases is fetched when nothing was sent.</summary>
|
||||||
|
public static readonly TimeSpan CasesInterval = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private DateTimeOffset? _casesFetchedAt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<Contracts.SupportCaseDto>(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<List<Contracts.SupportCaseDto>>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>How often aggregated experience from other customers is fetched.</summary>
|
/// <summary>How often aggregated experience from other customers is fetched.</summary>
|
||||||
public static readonly TimeSpan InsightsInterval = TimeSpan.FromHours(6);
|
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}")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "Could not fetch insights from the hub: {Message}")]
|
||||||
private static partial void LogInsightsFailed(ILogger logger, string 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")]
|
[LoggerMessage(Level = LogLevel.Error, Message = "Hub sync round failed")]
|
||||||
private static partial void LogRoundFailed(ILogger logger, Exception exception);
|
private static partial void LogRoundFailed(ILogger logger, Exception exception);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public sealed class DocsService
|
||||||
("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."),
|
("integration", "Prometheus, Grafana, JSON-API og SCOM via Windows' hændelseslog."),
|
||||||
("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."),
|
("rapporter", "Driftsrapport til ledelse og revision som PDF, også som månedlig mail."),
|
||||||
("regler", "Skriv egne regler i SQL, og bidrag med community-regler."),
|
("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."),
|
("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."),
|
("legacy-versions", "SQL Server 2000, 2005 og 2008: hvad der kan måles, og hvordan der forbindes."),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ public sealed record HubStatusRow
|
||||||
public DateTime? LastErrorAt { get; init; }
|
public DateTime? LastErrorAt { get; init; }
|
||||||
public DateTime UpdatedAt { get; init; }
|
public DateTime UpdatedAt { get; init; }
|
||||||
public int Pending { get; init; }
|
public int Pending { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowsSupportCases =>
|
||||||
|
Enabled && LicenseState is "Valid" or "ExpiredInGrace" && LicensePlan is "Professional" or "Enterprise";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class InsightRepository
|
public sealed partial class InsightRepository
|
||||||
|
|
|
||||||
144
src/SQLInsight.Repository/InsightRepository.Support.cs
Normal file
144
src/SQLInsight.Repository/InsightRepository.Support.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/SQLInsight.Repository/Migrations/0005_support_cases.sql
Normal file
36
src/SQLInsight.Repository/Migrations/0005_support_cases.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -14,5 +14,6 @@
|
||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes />
|
||||||
<script src="@Assets["app.js"]"></script>
|
<script src="@Assets["app.js"]"></script>
|
||||||
|
<script src="@Assets["support-menu.js"]"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
|
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
|
||||||
|
@inject InsightRepository Repository
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
|
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
|
||||||
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
|
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
|
||||||
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
|
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
|
||||||
|
<NavLink href="/support"><Icon Name="support" /> Support</NavLink>
|
||||||
|
|
||||||
<span class="nav-label">Community</span>
|
<span class="nav-label">Community</span>
|
||||||
<a href="@Options.Value.ForumUrl" rel="noopener"><Icon Name="users" /> Forum</a>
|
<a href="@Options.Value.ForumUrl" rel="noopener"><Icon Name="users" /> Forum</a>
|
||||||
|
|
@ -41,3 +43,15 @@
|
||||||
<footer class="footer">SQLInsight Pro · MIT-licens · <a href="https://git.seosoft.dk/seo/sqlinsight-pro">kildekode</a></footer>
|
<footer class="footer">SQLInsight Pro · MIT-licens · <a href="https://git.seosoft.dk/seo/sqlinsight-pro">kildekode</a></footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (_supportAvailable)
|
||||||
|
{
|
||||||
|
@* Tells support-menu.js that "Opret supportsag" may be offered on findings. *@
|
||||||
|
<div hidden data-support-available></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _supportAvailable;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() =>
|
||||||
|
_supportAvailable = (await Repository.GetHubStatusAsync())?.AllowsSupportCases == true;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<PageTitle>@_finding.Title · SQLInsight Pro</PageTitle>
|
<PageTitle>@_finding.Title · SQLInsight Pro</PageTitle>
|
||||||
|
|
||||||
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_finding.InstanceName)">@_finding.InstanceName</a> / @_finding.RuleId</p>
|
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_finding.InstanceName)">@_finding.InstanceName</a> / @_finding.RuleId</p>
|
||||||
<h1><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
|
<h1 data-finding-id="@_finding.FindingId" tabindex="0"><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
@if (_finding.ResolvedAt is { } resolved)
|
@if (_finding.ResolvedAt is { } resolved)
|
||||||
{
|
{
|
||||||
|
|
@ -21,6 +21,11 @@
|
||||||
Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt)
|
Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt)
|
||||||
@if (!string.IsNullOrEmpty(_finding.Subject)) { <span> · @_finding.Subject</span> }
|
@if (!string.IsNullOrEmpty(_finding.Subject)) { <span> · @_finding.Subject</span> }
|
||||||
</p>
|
</p>
|
||||||
|
@if (_support)
|
||||||
|
{
|
||||||
|
@* Also without JavaScript; with it, right-click on the finding gives the same choice. *@
|
||||||
|
<p><a class="button" href="/support?fund=@_finding.FindingId">Opret supportsag</a></p>
|
||||||
|
}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Hvorfor er det et problem?</h2>
|
<h2>Hvorfor er det et problem?</h2>
|
||||||
|
|
@ -88,10 +93,12 @@
|
||||||
private IReadOnlyList<SQLInsight.Contracts.UpgradeInsight> _elsewhere = [];
|
private IReadOnlyList<SQLInsight.Contracts.UpgradeInsight> _elsewhere = [];
|
||||||
private string? _version;
|
private string? _version;
|
||||||
private int _minCustomers;
|
private int _minCustomers;
|
||||||
|
private bool _support;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_finding = await Repository.GetFindingAsync(Id);
|
_finding = await Repository.GetFindingAsync(Id);
|
||||||
|
_support = _finding is not null && (await Repository.GetHubStatusAsync())?.AllowsSupportCases == true;
|
||||||
if (_finding is null || await Repository.GetHubInsightsJsonAsync() is not { } json)
|
if (_finding is null || await Repository.GetHubInsightsJsonAsync() is not { } json)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
101
src/SQLInsight.Web/Components/Pages/SupportPage.razor
Normal file
101
src/SQLInsight.Web/Components/Pages/SupportPage.razor
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
@page "/support"
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@inject InsightRepository Repository
|
||||||
|
@inject Display Display
|
||||||
|
|
||||||
|
<PageTitle>Support · SQLInsight Pro</PageTitle>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
@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>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(Fejl))
|
||||||
|
{
|
||||||
|
<p class="notice bad" role="alert">@Fejl</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_hub?.AllowsSupportCases == true)
|
||||||
|
{
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Opret supportsag</h2>
|
||||||
|
<form method="post" action="/support" class="support-form">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
@if (_finding is not null)
|
||||||
|
{
|
||||||
|
<input type="hidden" name="findingId" value="@_finding.FindingId" />
|
||||||
|
<p class="muted">Om fundet <a href="/fund/@_finding.FindingId">@_finding.Title</a> på @_finding.InstanceName.</p>
|
||||||
|
}
|
||||||
|
<label>Titel <input name="title" required maxlength="200" value="@_title" /></label>
|
||||||
|
<label>Beskrivelse <textarea name="description" rows="10" maxlength="8000">@_description</textarea></label>
|
||||||
|
<div class="support-contact">
|
||||||
|
<label>Kontaktperson <input name="contactName" required maxlength="200" value="@_contactName" autocomplete="name" /></label>
|
||||||
|
<label>E-mail <input name="contactEmail" type="email" required maxlength="320" autocomplete="email" /></label>
|
||||||
|
<label>Telefon (valgfri) <input name="contactPhone" type="tel" maxlength="50" autocomplete="tel" /></label>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Følsomme målinger (forespørgselstekst, logins, servernavne) er ikke sat ind i beskrivelsen. Tilføj kun det, I vil dele med SEOSoft.</p>
|
||||||
|
<button type="submit" class="button">Send til SEOSoft</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Supportsager kræver et abonnement</h2>
|
||||||
|
<p>Med Professional eller Enterprise kan I oprette supportsager direkte herfra og følge dem. Det kræver også, at deling med SQLInsight Hub er slået til (se <a href="/system">System</a>).</p>
|
||||||
|
<p>@(_hub?.LicenseMessage ?? "Agenten har ikke rapporteret licensstatus endnu.")</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Jeres sager</h2></div>
|
||||||
|
@if (_cases.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="empty">Ingen sager endnu.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _cases)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="mono nowrap">@(c.Reference ?? "–")</td>
|
||||||
|
<td>@c.Title @if (c.FindingId is { } fid) { <a class="muted" href="/fund/@fid">(fund)</a> }</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.ClosedAt)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private HttpContext? Http { get; set; }
|
||||||
|
[SupplyParameterFromQuery(Name = "fund")] public long? Fund { get; set; }
|
||||||
|
[SupplyParameterFromQuery(Name = "sendt")] public int? Sendt { get; set; }
|
||||||
|
[SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
|
||||||
|
|
||||||
|
private HubStatusRow? _hub;
|
||||||
|
private StoredFinding? _finding;
|
||||||
|
private IReadOnlyList<SupportCaseRow> _cases = [];
|
||||||
|
private string _title = string.Empty;
|
||||||
|
private string _description = string.Empty;
|
||||||
|
private string _contactName = string.Empty;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_hub = await Repository.GetHubStatusAsync();
|
||||||
|
_cases = await Repository.GetSupportCasesAsync();
|
||||||
|
_contactName = Support.ContactNameFrom(Http?.User.Identity?.Name);
|
||||||
|
if (Fund is { } id && (_finding = await Repository.GetFindingAsync(id)) is { } finding)
|
||||||
|
{
|
||||||
|
(_title, _description) = Support.Prefill(finding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -100,6 +100,7 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit
|
||||||
: Results.NotFound());
|
: Results.NotFound());
|
||||||
|
|
||||||
IntegrationEndpoints.Map(app);
|
IntegrationEndpoints.Map(app);
|
||||||
|
Support.Map(app);
|
||||||
|
|
||||||
// Management and compliance report as PDF, for the last N days.
|
// Management and compliance report as PDF, for the last N days.
|
||||||
app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
|
app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
|
||||||
|
|
|
||||||
88
src/SQLInsight.Web/Support.cs
Normal file
88
src/SQLInsight.Web/Support.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SQLInsight.Contracts;
|
||||||
|
using SQLInsight.Repository;
|
||||||
|
|
||||||
|
namespace SQLInsight.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Support cases with SEOSoft (Professional/Enterprise). The form posts here; the request is stored in the repository
|
||||||
|
/// and the agent sends it to SQLInsight Hub, which gives it a case number. The web UI never talks to the hub itself.
|
||||||
|
/// </summary>
|
||||||
|
public static class Support
|
||||||
|
{
|
||||||
|
public static void Map(IEndpointRouteBuilder app) => app.MapPost("/support", CreateAsync);
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(finding);
|
||||||
|
var text = new StringBuilder();
|
||||||
|
text.Append("Instans: ").Append(finding.InstanceName).Append('\n');
|
||||||
|
text.Append("Regel: ").Append(finding.RuleId).Append('\n');
|
||||||
|
text.Append("Alvor: ").Append(Display.SeverityName(finding.Severity)).Append('\n');
|
||||||
|
if (!string.IsNullOrEmpty(finding.Subject))
|
||||||
|
{
|
||||||
|
text.Append("Emne: ").Append(finding.Subject).Append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
text.Append('\n').Append(finding.Explanation).Append('\n');
|
||||||
|
var evidence = finding.Evidence.Where(e => !e.Sensitive).Take(10).ToList();
|
||||||
|
if (evidence.Count > 0)
|
||||||
|
{
|
||||||
|
text.Append("\nMålinger:\n");
|
||||||
|
foreach (var e in evidence)
|
||||||
|
{
|
||||||
|
text.Append("- ").Append(e.Name).Append(": ").Append(e.Value.Length > 200 ? e.Value[..200] + "…" : e.Value).Append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text.Append("\nHvad har I prøvet, og hvad har ændret sig?\n");
|
||||||
|
var title = finding.Title.Length > SupportCaseRequest.MaxTitle ? finding.Title[..SupportCaseRequest.MaxTitle] : finding.Title;
|
||||||
|
var description = text.ToString();
|
||||||
|
return (title, description.Length > SupportCaseRequest.MaxDescription ? description[..SupportCaseRequest.MaxDescription] : description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The user part of DOMAIN\user, as a starting point for the contact name.</summary>
|
||||||
|
public static string ContactNameFrom(string? windowsUser) =>
|
||||||
|
windowsUser is { Length: > 0 } user ? user[(user.LastIndexOf('\\') + 1)..] : string.Empty;
|
||||||
|
|
||||||
|
public static string StatusText(SupportCaseRow row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
return row.Status switch
|
||||||
|
{
|
||||||
|
SupportCaseStatus.Pending => row.Attempts > 0 ? "Venter på hubben (prøver igen)" : "Sendes",
|
||||||
|
SupportCaseStatus.Open => "Åben",
|
||||||
|
SupportCaseStatus.Closed => "Lukket",
|
||||||
|
_ => "Afvist",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateAsync(
|
||||||
|
HttpContext http, InsightRepository repository,
|
||||||
|
[FromForm] string? title, [FromForm] string? description, [FromForm] string? contactName, [FromForm] string? contactEmail,
|
||||||
|
[FromForm] string? contactPhone, [FromForm] long? findingId)
|
||||||
|
{
|
||||||
|
var back = findingId is { } id ? $"&fund={id}" : string.Empty;
|
||||||
|
if ((await repository.GetHubStatusAsync(http.RequestAborted))?.AllowsSupportCases != true)
|
||||||
|
{
|
||||||
|
return Results.Redirect("/support?fejl=" + Uri.EscapeDataString("Supportsager kræver Professional eller Enterprise og at deling med SQLInsight Hub er slået til."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var finding = findingId is { } fid ? await repository.GetFindingAsync(fid, http.RequestAborted) : null;
|
||||||
|
var request = new SupportCaseRequest(Guid.NewGuid(), title?.Trim() ?? string.Empty, Blank(description), contactName?.Trim() ?? string.Empty,
|
||||||
|
contactEmail?.Trim() ?? string.Empty, Blank(contactPhone), http.User.Identity?.Name, finding?.InstanceName, finding?.RuleId, finding?.Subject);
|
||||||
|
if (request.Validate() is { } invalid)
|
||||||
|
{
|
||||||
|
return Results.Redirect("/support?fejl=" + Uri.EscapeDataString(invalid) + back);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.AddSupportRequestAsync(new NewSupportRequest(request.ClientRequestId, request.Title, request.Description, request.ContactName,
|
||||||
|
request.ContactEmail, request.ContactPhone, request.RequestedBy, finding?.FindingId, finding?.InstanceName, finding?.RuleId, finding?.Subject),
|
||||||
|
DateTimeOffset.UtcNow, http.RequestAborted);
|
||||||
|
return Results.Redirect("/support?sendt=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
|
@ -161,3 +161,17 @@ details .script pre { max-height: 32rem; }
|
||||||
a.button { display: inline-block; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
|
a.button { display: inline-block; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
|
||||||
a.button:hover { filter: brightness(1.08); }
|
a.button:hover { filter: brightness(1.08); }
|
||||||
.panel.insight { border-left: 4px solid var(--accent); }
|
.panel.insight { border-left: 4px solid var(--accent); }
|
||||||
|
|
||||||
|
/* Support cases: the form, notices and the "Opret supportsag" context menu on findings (support-menu.js). */
|
||||||
|
.support-form { display: grid; gap: .9rem; max-width: 52rem; }
|
||||||
|
.support-form label { display: grid; gap: .3rem; font-size: .85rem; color: var(--text-2); }
|
||||||
|
.support-form input, .support-form textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .65rem; }
|
||||||
|
.support-form textarea { resize: vertical; }
|
||||||
|
.support-contact { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .9rem; }
|
||||||
|
.support-form button.button { justify-self: start; border: 0; cursor: pointer; font: inherit; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; }
|
||||||
|
.notice { border-radius: var(--radius); padding: .75rem 1rem; margin: 0 0 1.25rem; border: 1px solid var(--border); background: var(--surface); }
|
||||||
|
.notice.ok { border-left: 4px solid var(--accent); }
|
||||||
|
.notice.bad { border-left: 4px solid #d92d20; }
|
||||||
|
.context-menu { position: absolute; z-index: 1000; min-width: 12rem; padding: .3rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); }
|
||||||
|
.context-menu a { display: block; padding: .45rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; }
|
||||||
|
.context-menu a:hover, .context-menu a:focus { background: var(--surface-2); outline: none; }
|
||||||
|
|
|
||||||
78
src/SQLInsight.Web/wwwroot/support-menu.js
Normal file
78
src/SQLInsight.Web/wwwroot/support-menu.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// "Opret supportsag" in a context menu on findings: right-click a link to /fund/<id> (any finding list) or the
|
||||||
|
// heading of a finding's own page. Keyboard: Shift+F10 or the context menu key on a focused finding; Escape closes.
|
||||||
|
// Only when the layout says support is available (data-support-available). Without JavaScript the finding page has a
|
||||||
|
// visible "Opret supportsag" button, so nothing depends on this file.
|
||||||
|
(() => {
|
||||||
|
const available = () => document.querySelector('[data-support-available]') !== null;
|
||||||
|
let menu = null;
|
||||||
|
let opener = null;
|
||||||
|
|
||||||
|
const findingId = (element) => {
|
||||||
|
const marked = element.closest('[data-finding-id]');
|
||||||
|
if (marked) return marked.dataset.findingId;
|
||||||
|
const link = element.closest('a[href]');
|
||||||
|
const match = link && /^\/fund\/(\d+)$/.exec(new URL(link.href, document.baseURI).pathname);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = (restoreFocus) => {
|
||||||
|
if (!menu) return;
|
||||||
|
menu.remove();
|
||||||
|
menu = null;
|
||||||
|
if (restoreFocus && opener) opener.focus();
|
||||||
|
opener = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (id, x, y, target) => {
|
||||||
|
close(false);
|
||||||
|
opener = target.closest('a[href], [tabindex]') || target;
|
||||||
|
menu = document.createElement('div');
|
||||||
|
menu.className = 'context-menu';
|
||||||
|
menu.setAttribute('role', 'menu');
|
||||||
|
menu.setAttribute('aria-label', 'Fund');
|
||||||
|
const item = document.createElement('a');
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
item.href = `/support?fund=${encodeURIComponent(id)}`;
|
||||||
|
item.textContent = 'Opret supportsag';
|
||||||
|
menu.appendChild(item);
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
// Keep it inside the window. Setting style through the CSSOM is allowed by the page's Content-Security-Policy.
|
||||||
|
const width = menu.offsetWidth;
|
||||||
|
const height = menu.offsetHeight;
|
||||||
|
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4)) + window.scrollX}px`;
|
||||||
|
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4)) + window.scrollY}px`;
|
||||||
|
item.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('contextmenu', (event) => {
|
||||||
|
if (!available()) return;
|
||||||
|
const id = findingId(event.target);
|
||||||
|
if (!id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
// From the keyboard there is no pointer position: place the menu under the focused element.
|
||||||
|
let { clientX: x, clientY: y } = event;
|
||||||
|
if (x === 0 && y === 0) {
|
||||||
|
const box = event.target.getBoundingClientRect();
|
||||||
|
x = box.left;
|
||||||
|
y = box.bottom + 4;
|
||||||
|
}
|
||||||
|
open(id, x, y, event.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (!menu) return;
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close(true);
|
||||||
|
} else if (event.key === 'Tab') {
|
||||||
|
close(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', (event) => {
|
||||||
|
if (menu && !menu.contains(event.target)) close(false);
|
||||||
|
});
|
||||||
|
window.addEventListener('scroll', () => close(false), { passive: true });
|
||||||
|
window.addEventListener('resize', () => close(false));
|
||||||
|
})();
|
||||||
81
tests/SQLInsight.IntegrationTests/HubSupportEndToEndTests.cs
Normal file
81
tests/SQLInsight.IntegrationTests/HubSupportEndToEndTests.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SQLInsight.Agent.Hub;
|
||||||
|
using SQLInsight.Hub.Data;
|
||||||
|
using SQLInsight.Repository;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>A support case from SQLInsight Web, through the agent to the hub and its status back again.</summary>
|
||||||
|
[Collection(SqlServerTestGroup.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public class HubSupportEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixture<HubFixture>, IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _database = "SQLInsightSupE2E_" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
private InsightRepository _repository = null!;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
||||||
|
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
|
||||||
|
await _repository.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
||||||
|
|
||||||
|
private sealed class TestServerClients(Func<HttpClient> create) : IHttpClientFactory
|
||||||
|
{
|
||||||
|
public HttpClient CreateClient(string name) => create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HubSyncService Sync(string license)
|
||||||
|
{
|
||||||
|
var options = Options.Create(new 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Case_filed_in_web_gets_a_number_and_its_closing_comes_back()
|
||||||
|
{
|
||||||
|
var customer = $"Support E2E {Guid.NewGuid():N}"[..28];
|
||||||
|
var license = hub.License(customer);
|
||||||
|
await _repository.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Langsom backup", "Siden mandag", "Anna", "anna@kunde.dk", null, "KUNDE\\anna"), DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await Sync(license).RunOnceAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var filed = Assert.Single(await _repository.GetSupportCasesAsync());
|
||||||
|
Assert.Equal(("SUP-000001", SupportCaseStatus.Open), (filed.Reference, filed.Status));
|
||||||
|
|
||||||
|
await using (var scope = hub.Factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
|
||||||
|
var stored = await db.SupportCases.SingleAsync(c => c.Customer!.Name == customer);
|
||||||
|
stored.IsOpen = false;
|
||||||
|
stored.ClosedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Sync(license).RunOnceAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var closed = Assert.Single(await _repository.GetSupportCasesAsync());
|
||||||
|
Assert.Equal(SupportCaseStatus.Closed, closed.Status);
|
||||||
|
Assert.NotNull(closed.ClosedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Request_the_license_does_not_cover_is_given_up()
|
||||||
|
{
|
||||||
|
var customer = $"Community {Guid.NewGuid():N}"[..24];
|
||||||
|
await _repository.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Hjælp", null, "Anna", "anna@kunde.dk", null, null), DateTimeOffset.UtcNow);
|
||||||
|
var sync = Sync(hub.License(customer));
|
||||||
|
|
||||||
|
await sync.SyncSupportCasesAsync(hub.Factory.CreateClient(), new HubOptions { Url = new Uri("http://localhost/"), License = hub.License(customer, SQLInsight.Licensing.LicensePlan.Community) }, CancellationToken.None);
|
||||||
|
|
||||||
|
var failed = Assert.Single(await _repository.GetSupportCasesAsync());
|
||||||
|
Assert.Equal(SupportCaseStatus.Failed, failed.Status);
|
||||||
|
Assert.Contains("403", failed.LastError, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
tests/SQLInsight.IntegrationTests/WebSupportTests.cs
Normal file
175
tests/SQLInsight.IntegrationTests/WebSupportTests.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Repository;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Support cases in SQLInsight Web: form, prefill from a finding, and filing with only read rights.</summary>
|
||||||
|
[Collection(SqlServerTestGroup.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public partial class WebSupportTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _database = "SQLInsightSup_" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
private WebApplicationFactory<Program> _factory = null!;
|
||||||
|
private InsightRepository _repository = null!;
|
||||||
|
private long _findingId;
|
||||||
|
|
||||||
|
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
|
||||||
|
private static partial Regex AntiforgeryField();
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
||||||
|
var connectionString = sql.DatabaseConnectionString(_database);
|
||||||
|
_repository = new InsightRepository(connectionString);
|
||||||
|
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 er ikke sat", 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 max server memory.",
|
||||||
|
Evidence = [new Evidence("max server memory (MB)", "2147483647"), new Evidence("login", "KUNDE\\hemmelig", Sensitive: true)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
], DateTimeOffset.UtcNow);
|
||||||
|
_findingId = (await _repository.GetActiveFindingsAsync(id)).Single().FindingId;
|
||||||
|
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||||
|
{
|
||||||
|
b.UseEnvironment("Development");
|
||||||
|
b.UseSetting("SQLInsight:Repository:ConnectionString", connectionString);
|
||||||
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _factory.DisposeAsync();
|
||||||
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task AllowSupportAsync() => _repository.SaveHubStatusAsync(new HubStatusRow
|
||||||
|
{
|
||||||
|
Enabled = true, HubUrl = "https://hub.example/", LicenseState = "Valid", LicenseMessage = "Professional til Kunde A/S", Customer = "Kunde A/S",
|
||||||
|
LicensePlan = "Professional", LicenseExpires = DateTime.UtcNow.AddYears(1), UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_support_license_there_is_no_form_and_no_menu()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var support = await client.GetStringAsync("/support");
|
||||||
|
var finding = await client.GetStringAsync($"/fund/{_findingId}");
|
||||||
|
|
||||||
|
Assert.Contains("Supportsager kræver et abonnement", support, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("name=\"contactEmail\"", support, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("data-support-available", finding, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("/support?fund=", finding, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Finding_offers_a_case_that_is_prefilled_and_filed()
|
||||||
|
{
|
||||||
|
await AllowSupportAsync();
|
||||||
|
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
var finding = await client.GetStringAsync($"/fund/{_findingId}");
|
||||||
|
Assert.Contains("data-support-available", finding, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"data-finding-id=\"{_findingId}\"", finding, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"href=\"/support?fund={_findingId}\"", finding, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("support-menu", finding, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var form = await client.GetStringAsync($"/support?fund={_findingId}");
|
||||||
|
Assert.Contains("value=\"max server memory er ikke sat\"", form, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Regel: SQLI-CFG-001", form, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("max server memory (MB): 2147483647", form, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("hemmelig", form, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var token = WebUtility.HtmlDecode(AntiforgeryField().Match(form).Groups[1].Value);
|
||||||
|
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["__RequestVerificationToken"] = token, ["findingId"] = _findingId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
["title"] = "max server memory er ikke sat", ["description"] = "Hjælp", ["contactName"] = "Anna", ["contactEmail"] = "anna@kunde.dk",
|
||||||
|
});
|
||||||
|
var posted = await client.PostAsync("/support", content);
|
||||||
|
|
||||||
|
Assert.Equal("/support?sendt=1", posted.Headers.Location?.OriginalString);
|
||||||
|
var stored = Assert.Single(await _repository.GetSupportCasesAsync());
|
||||||
|
Assert.Equal((SupportCaseStatus.Pending, "SQLI-CFG-001", "prod-sql01", _findingId), (stored.Status, stored.RuleId, stored.InstanceName, stored.FindingId!.Value));
|
||||||
|
Assert.Contains("Sendes", await client.GetStringAsync("/support"), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalid_request_is_sent_back_with_the_reason()
|
||||||
|
{
|
||||||
|
await AllowSupportAsync();
|
||||||
|
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||||
|
var token = WebUtility.HtmlDecode(AntiforgeryField().Match(await client.GetStringAsync("/support")).Groups[1].Value);
|
||||||
|
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["__RequestVerificationToken"] = token, ["title"] = "Hjælp", ["contactName"] = "Anna", ["contactEmail"] = "uden-at",
|
||||||
|
});
|
||||||
|
|
||||||
|
var posted = await client.PostAsync("/support", content);
|
||||||
|
|
||||||
|
Assert.StartsWith("/support?fejl=", posted.Headers.Location?.OriginalString, StringComparison.Ordinal);
|
||||||
|
Assert.Empty(await _repository.GetSupportCasesAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_read_only_login_can_file_a_request_but_not_change_cases()
|
||||||
|
{
|
||||||
|
var login = "web_" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
const string password = "Read-Only-Login-1!";
|
||||||
|
await sql.ExecuteAsSaAsync($"""
|
||||||
|
CREATE LOGIN [{login}] WITH PASSWORD = N'{password}', CHECK_POLICY = OFF;
|
||||||
|
USE [{_database}]; CREATE USER [{login}] FOR LOGIN [{login}]; ALTER ROLE db_datareader ADD MEMBER [{login}];
|
||||||
|
""");
|
||||||
|
var readOnly = new InsightRepository(new SqlConnectionStringBuilder(sql.DatabaseConnectionString(_database))
|
||||||
|
{
|
||||||
|
UserID = login, Password = password, IntegratedSecurity = false,
|
||||||
|
}.ConnectionString);
|
||||||
|
|
||||||
|
var id = await readOnly.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Hjælp", null, "Anna", "anna@kunde.dk", null, "KUNDE\\anna"), DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Assert.True(id > 0);
|
||||||
|
await Assert.ThrowsAsync<SqlException>(() => readOnly.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hub_cases_are_merged_into_the_local_list()
|
||||||
|
{
|
||||||
|
var request = Guid.NewGuid();
|
||||||
|
await _repository.AddSupportRequestAsync(new NewSupportRequest(request, "Fra kunden", null, "Anna", "anna@kunde.dk", null, null), DateTimeOffset.UtcNow);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _repository.SyncSupportCasesAsync(
|
||||||
|
[
|
||||||
|
new HubSupportCase("SUP-000001", "Fra kunden", false, now.AddDays(-1), now, "Customer", request),
|
||||||
|
new HubSupportCase("SUP-000002", "Fra SEOSoft", true, now, null, "Staff", null),
|
||||||
|
], DateTimeOffset.UtcNow);
|
||||||
|
await _repository.SyncSupportCasesAsync([new HubSupportCase("SUP-000002", "Fra SEOSoft", true, now, null, "Staff", null)], DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
var cases = await _repository.GetSupportCasesAsync();
|
||||||
|
Assert.Equal(2, cases.Count);
|
||||||
|
Assert.Equal((SupportCaseStatus.Closed, "SUP-000001"), cases.Where(c => c.ClientRequestId == request).Select(c => (c.Status, c.Reference)).Single());
|
||||||
|
Assert.Equal(SupportCaseStatus.Open, cases.Single(c => c.Reference == "SUP-000002").Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue