Compare commits
3 commits
6d755aa3d0
...
4593ee8b09
| Author | SHA1 | Date | |
|---|---|---|---|
| 4593ee8b09 | |||
| 50bc878776 | |||
| 83c247d536 |
50 changed files with 3068 additions and 51 deletions
|
|
@ -9,7 +9,9 @@
|
|||
# Alle miljøværdier (også IP'er) kommer fra Forgejo repo-secrets.
|
||||
#
|
||||
# Påkrævede secrets: HOST_BIND HOST_PORT DB_SERVER DB_PORT DB_NAME DB_USER DB_PASSWORD KNOWN_PROXY
|
||||
# Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (offentlig licensnøgle fra keygen)
|
||||
# Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (ekstra offentlig licensnøgle; k20261001 er indbygget)
|
||||
# Valgfri: mail til support ved nye kundesager: SUPPORT_SMTP_HOST SUPPORT_SMTP_PORT (587) SUPPORT_SMTP_USER
|
||||
# SUPPORT_SMTP_PASSWORD SUPPORT_SMTP_FROM SUPPORT_SMTP_TO (kommasepareret) SUPPORT_PORTAL_URL (link i mailen)
|
||||
# =============================================================================
|
||||
name: deploy hub
|
||||
|
||||
|
|
@ -59,6 +61,13 @@ jobs:
|
|||
KNOWN_PROXY: ${{ secrets.KNOWN_PROXY }}
|
||||
TRUSTED_KEY_ID: ${{ secrets.TRUSTED_KEY_ID }}
|
||||
TRUSTED_KEY: ${{ secrets.TRUSTED_KEY }}
|
||||
SUPPORT_SMTP_HOST: ${{ secrets.SUPPORT_SMTP_HOST }}
|
||||
SUPPORT_SMTP_PORT: ${{ secrets.SUPPORT_SMTP_PORT }}
|
||||
SUPPORT_SMTP_USER: ${{ secrets.SUPPORT_SMTP_USER }}
|
||||
SUPPORT_SMTP_PASSWORD: ${{ secrets.SUPPORT_SMTP_PASSWORD }}
|
||||
SUPPORT_SMTP_FROM: ${{ secrets.SUPPORT_SMTP_FROM }}
|
||||
SUPPORT_SMTP_TO: ${{ secrets.SUPPORT_SMTP_TO }}
|
||||
SUPPORT_PORTAL_URL: ${{ secrets.SUPPORT_PORTAL_URL }}
|
||||
run: |
|
||||
set -eu
|
||||
required="HOST_BIND HOST_PORT DB_SERVER DB_PORT DB_NAME DB_USER DB_PASSWORD KNOWN_PROXY"
|
||||
|
|
@ -77,6 +86,12 @@ jobs:
|
|||
*"'"*|*";"*) echo "ERROR: secret $v indeholder ' eller ; – ikke understøttet"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Mail-værdierne må gerne indeholde ; (fx i et kodeord), men ikke '
|
||||
for v in SUPPORT_SMTP_HOST SUPPORT_SMTP_PORT SUPPORT_SMTP_USER SUPPORT_SMTP_PASSWORD SUPPORT_SMTP_FROM SUPPORT_SMTP_TO SUPPORT_PORTAL_URL; do
|
||||
case "$(printenv "$v" || true)" in
|
||||
*"'"*) echo "ERROR: secret $v indeholder ' – ikke understøttet"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
{
|
||||
|
|
@ -87,8 +102,24 @@ jobs:
|
|||
printf "SQLInsight__Hub__KnownProxies__0='%s'\n" "$KNOWN_PROXY"
|
||||
if [ -n "${TRUSTED_KEY_ID:-}" ] && [ -n "${TRUSTED_KEY:-}" ]; then
|
||||
printf "SQLInsight__Hub__TrustedKeys__%s='%s'\n" "$TRUSTED_KEY_ID" "$TRUSTED_KEY"
|
||||
fi
|
||||
# Mail til support, når en kunde opretter en sag (kun når host, afsender og modtager er sat)
|
||||
if [ -n "${SUPPORT_SMTP_HOST:-}" ] && [ -n "${SUPPORT_SMTP_FROM:-}" ] && [ -n "${SUPPORT_SMTP_TO:-}" ]; then
|
||||
n="SQLInsight__Hub__Support__Notify__"
|
||||
printf "%sEnabled='true'\n" "$n"
|
||||
printf "%sSmtpHost='%s'\n" "$n" "$SUPPORT_SMTP_HOST"
|
||||
printf "%sSmtpPort='%s'\n" "$n" "${SUPPORT_SMTP_PORT:-587}"
|
||||
printf "%sFrom='%s'\n" "$n" "$SUPPORT_SMTP_FROM"
|
||||
[ -z "${SUPPORT_SMTP_USER:-}" ] || printf "%sUserName='%s'\n" "$n" "$SUPPORT_SMTP_USER"
|
||||
[ -z "${SUPPORT_SMTP_PASSWORD:-}" ] || printf "%sPassword='%s'\n" "$n" "$SUPPORT_SMTP_PASSWORD"
|
||||
[ -z "${SUPPORT_PORTAL_URL:-}" ] || printf "%sPortalUrl='%s'\n" "$n" "$SUPPORT_PORTAL_URL"
|
||||
i=0
|
||||
for to in $(printf "%s" "$SUPPORT_SMTP_TO" | tr "," " "); do
|
||||
printf "%sTo__%s='%s'\n" "$n" "$i" "$to"
|
||||
i=$((i + 1))
|
||||
done
|
||||
else
|
||||
echo "WARNING: TRUSTED_KEY_ID/TRUSTED_KEY ikke sat – hubben kan ikke verificere licenser" >&2
|
||||
echo "NOTE: SUPPORT_SMTP_* ikke sat – nye kundesager vises kun i portalen (ingen mail)" >&2
|
||||
fi
|
||||
} > "$tmp/.env"
|
||||
cp deploy/hub/docker-compose.prod.yml "$tmp/docker-compose.yml"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
22
docs/hub.md
22
docs/hub.md
|
|
@ -61,6 +61,28 @@ Hubben registrerer, når en instans skifter build, og beregner, hvilke fund der
|
|||
|
||||
Regler om selve versionen (SQLI-VER-*) er udeladt, fordi de altid forsvinder ved en opdatering.
|
||||
|
||||
## Supportsager
|
||||
|
||||
**Sagsnumre** tildeles pr. kunde: `SUP-000001`, `SUP-000002` … Portalen foreslår det næste nummer, når en sag oprettes fra et fund eller under **Ny sag** på kundens side. Feltet kan rettes (fx til et nummer fra et andet system), men et nummer kan kun bruges én gang pr. kunde. Håndskrevne numre, der ikke har formen `SUP-<tal>`, påvirker ikke rækkefølgen.
|
||||
|
||||
**Kundens egne sager:** betalende kunder (Professional/Enterprise) opretter sager i SQLInsight Web (se [support.md](support.md)). Agenten sender dem til `POST /api/v1/cases` med licensen og henter status for kundens sager med `GET /api/v1/cases`. Kundeoprettede sager er markeret *Kunde* under **Supportsager** med kontaktperson og beskrivelse, og er knyttet til fundet, hvis sagen blev oprettet fra et.
|
||||
|
||||
**Mail til support,** når en kunde opretter en sag (valgfri). Uden indstillingerne vises nye sager kun i portalen. Med Docker Compose/Forgejo sættes de som repo-secrets (`SUPPORT_SMTP_HOST`, `SUPPORT_SMTP_PORT`, `SUPPORT_SMTP_USER`, `SUPPORT_SMTP_PASSWORD`, `SUPPORT_SMTP_FROM`, `SUPPORT_SMTP_TO` kommasepareret, `SUPPORT_PORTAL_URL`), ellers i `appsettings.json`:
|
||||
|
||||
```json
|
||||
"Support": {
|
||||
"Notify": {
|
||||
"Enabled": true, "SmtpHost": "smtp.office365.com", "SmtpPort": 587, "StartTls": true,
|
||||
"UserName": "support@seosoft.dk", "Password": "…", "From": "support@seosoft.dk",
|
||||
"To": [ "support@seosoft.dk" ], "PortalUrl": "https://hub.sqlinsight.seosoft.dk/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Microsoft 365 kræver, at *Authenticated SMTP* er slået til for postkassen; hubben logger ind med AUTH LOGIN. Svar på mailen går til kundens kontaktperson. En mail, der ikke kan sendes, logges; sagen er gemt alligevel.
|
||||
|
||||
**Supportrapport** (portalen → **Supportrapport**): for en periode (standard de seneste 90 dage) og alle kunder eller én: oprettede, lukkede og åbne sager, gennemsnitlig og median løsningstid (fra oprettelse til lukning), ældste åbne sag og andelen, kunden selv har oprettet, samt alle sager i perioden. **Hent CSV** giver listen til Excel (UTF-8, semikolon).
|
||||
|
||||
## Licenser
|
||||
|
||||
```bash
|
||||
|
|
|
|||
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).
|
||||
13
dotnet-tools.json
Normal file
13
dotnet-tools.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.12",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
];
|
||||
|
|
|
|||
83
src/SQLInsight.Contracts/SupportCases.cs
Normal file
83
src/SQLInsight.Contracts/SupportCases.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
namespace SQLInsight.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A support case a paying customer creates in SQLInsight Web, sent by the agent to POST /api/v1/cases.
|
||||
/// <see cref="ClientRequestId"/> makes the request idempotent: a retry returns the case already created.
|
||||
/// The finding fields are optional; when given, the hub links the customer's matching active finding to the case.
|
||||
/// </summary>
|
||||
public sealed record SupportCaseRequest(
|
||||
Guid ClientRequestId,
|
||||
string Title,
|
||||
string? Description,
|
||||
string ContactName,
|
||||
string ContactEmail,
|
||||
string? ContactPhone,
|
||||
string? RequestedBy,
|
||||
string? InstanceName = null,
|
||||
string? RuleId = null,
|
||||
string? Subject = null)
|
||||
{
|
||||
public const int MaxTitle = 200;
|
||||
public const int MaxDescription = 8000;
|
||||
public const int MaxContactName = 200;
|
||||
public const int MaxContactEmail = 320;
|
||||
public const int MaxContactPhone = 50;
|
||||
public const int MaxRequestedBy = 256;
|
||||
public const int MaxInstanceName = 128;
|
||||
public const int MaxRuleId = 50;
|
||||
public const int MaxSubject = 400;
|
||||
|
||||
/// <summary>Why the request cannot be accepted, or null when it is valid.</summary>
|
||||
public string? Validate()
|
||||
{
|
||||
if (ClientRequestId == Guid.Empty)
|
||||
{
|
||||
return "ClientRequestId mangler.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Title) || Title.Length > MaxTitle)
|
||||
{
|
||||
return $"Titel skal udfyldes (højst {MaxTitle} tegn).";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ContactName) || ContactName.Length > MaxContactName)
|
||||
{
|
||||
return $"Kontaktperson skal udfyldes (højst {MaxContactName} tegn).";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ContactEmail) || ContactEmail.Length > MaxContactEmail || !ContactEmail.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return "E-mail skal være en gyldig adresse.";
|
||||
}
|
||||
|
||||
return (Description?.Length > MaxDescription, ContactPhone?.Length > MaxContactPhone, RequestedBy?.Length > MaxRequestedBy,
|
||||
InstanceName?.Length > MaxInstanceName, RuleId?.Length > MaxRuleId, Subject?.Length > MaxSubject) switch
|
||||
{
|
||||
(true, _, _, _, _, _) => $"Beskrivelsen må højst være {MaxDescription} tegn.",
|
||||
(_, true, _, _, _, _) => $"Telefonnummeret må højst være {MaxContactPhone} tegn.",
|
||||
(_, _, true, _, _, _) => "Brugernavnet er for langt.",
|
||||
(_, _, _, true, _, _) or (_, _, _, _, true, _) or (_, _, _, _, _, true) => "Fundets oplysninger er for lange.",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A support case as the hub reports it to the customer's agent (POST and GET /api/v1/cases).</summary>
|
||||
public sealed record SupportCaseDto(
|
||||
string Reference,
|
||||
string Title,
|
||||
bool IsOpen,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ClosedAt,
|
||||
string Source,
|
||||
Guid? ClientRequestId);
|
||||
|
||||
/// <summary>Who created a support case.</summary>
|
||||
public static class SupportCaseSources
|
||||
{
|
||||
/// <summary>SEOSoft staff, in the hub portal.</summary>
|
||||
public const string Staff = "Staff";
|
||||
|
||||
/// <summary>The customer, in SQLInsight Web.</summary>
|
||||
public const string Customer = "Customer";
|
||||
}
|
||||
142
src/SQLInsight.Hub/Api/CasesEndpoint.cs
Normal file
142
src/SQLInsight.Hub/Api/CasesEndpoint.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SQLInsight.Contracts;
|
||||
using SQLInsight.Hub.Data;
|
||||
using SQLInsight.Hub.Support;
|
||||
|
||||
namespace SQLInsight.Hub.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Support cases for paying customers, through their agent:
|
||||
/// POST /api/v1/cases creates a case (idempotent on ClientRequestId) and returns its number;
|
||||
/// GET /api/v1/cases lists the customer's cases, also those SEOSoft created, so SQLInsight Web can show their status.
|
||||
/// </summary>
|
||||
public static class CasesEndpoint
|
||||
{
|
||||
private const int MaxListed = 200;
|
||||
|
||||
public static void Map(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous();
|
||||
app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous();
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CreateAsync(
|
||||
HttpRequest request, HubDbContext db, IOptions<HubServerOptions> options, SupportNotifier notifier, 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.");
|
||||
}
|
||||
|
||||
if (request.ContentLength > 64 * 1024)
|
||||
{
|
||||
return FindingsEndpoint.Problem(413, "For stor", "Højst 64 KB.");
|
||||
}
|
||||
|
||||
SupportCaseRequest? body;
|
||||
try
|
||||
{
|
||||
body = await JsonSerializer.DeserializeAsync<SupportCaseRequest>(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return FindingsEndpoint.Problem(400, "Ugyldig JSON", ex.Message);
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return FindingsEndpoint.Problem(400, "Ugyldig sag", "Body mangler.");
|
||||
}
|
||||
|
||||
if (body.Validate() is { } invalid)
|
||||
{
|
||||
return FindingsEndpoint.Problem(422, "Ugyldig sag", invalid);
|
||||
}
|
||||
|
||||
var now = time.GetUtcNow().UtcDateTime;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await CustomerRegistration.LockAsync(db, license.Customer, cancellationToken).ConfigureAwait(false);
|
||||
var record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false);
|
||||
if (record?.RevokedAt is not null)
|
||||
{
|
||||
return FindingsEndpoint.Problem(403, "Licensen er spærret", record.RevokedReason ?? "Kontakt SQLInsight-support.");
|
||||
}
|
||||
|
||||
var (customer, _) = await CustomerRegistration.EnsureAsync(db, license, record, now, cancellationToken).ConfigureAwait(false);
|
||||
var existing = await db.SupportCases.SingleOrDefaultAsync(c => c.CustomerId == customer.Id && c.ClientRequestId == body.ClientRequestId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(SupportCaseStore.ToDto(existing));
|
||||
}
|
||||
|
||||
var (created, error) = await SupportCaseStore.AddAsync(db, new SupportCase
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
Reference = string.Empty,
|
||||
Title = body.Title.Trim(),
|
||||
Description = Trimmed(body.Description),
|
||||
ContactName = body.ContactName.Trim(),
|
||||
ContactEmail = body.ContactEmail.Trim(),
|
||||
ContactPhone = Trimmed(body.ContactPhone),
|
||||
RequestedBy = Trimmed(body.RequestedBy),
|
||||
ClientRequestId = body.ClientRequestId,
|
||||
Source = SupportCaseSources.Customer,
|
||||
CreatedAt = now,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
if (created is null)
|
||||
{
|
||||
return FindingsEndpoint.Problem(409, "Sagen kunne ikke oprettes", error!);
|
||||
}
|
||||
|
||||
// The finding the customer created the case from, if the hub has it as active and not yet in a case.
|
||||
if (body.InstanceName is { Length: > 0 } instance && body.RuleId is { Length: > 0 } rule && body.Subject is { } subject)
|
||||
{
|
||||
var finding = await db.Findings.SingleOrDefaultAsync(f => f.Instance!.CustomerId == customer.Id && f.Instance.InstanceKey == instance
|
||||
&& f.RuleId == rule && f.Subject == subject && f.ResolvedAt == null && f.SupportCaseId == null, cancellationToken).ConfigureAwait(false);
|
||||
if (finding is not null)
|
||||
{
|
||||
finding.SupportCaseId = created.Id;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await notifier.NotifyAsync(created, customer.Name, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/cases/{Uri.EscapeDataString(created.Reference)}", SupportCaseStore.ToDto(created));
|
||||
}
|
||||
|
||||
internal static async Task<IResult> ListAsync(
|
||||
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.");
|
||||
}
|
||||
|
||||
var cases = await db.SupportCases.AsNoTracking()
|
||||
.Where(c => c.Customer!.Name == license.Customer)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(MaxListed)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList());
|
||||
}
|
||||
|
||||
private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
42
src/SQLInsight.Hub/Api/CustomerRegistration.cs
Normal file
42
src/SQLInsight.Hub/Api/CustomerRegistration.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using SQLInsight.Hub.Data;
|
||||
using SQLInsight.Licensing;
|
||||
|
||||
namespace SQLInsight.Hub.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The customer and license record behind a verified license, created on first contact. Callers hold the
|
||||
/// customer's advisory lock (<see cref="LockAsync"/>) in their transaction, so two first requests do not race.
|
||||
/// </summary>
|
||||
internal static class CustomerRegistration
|
||||
{
|
||||
/// <summary>Serializes everything for one customer until the transaction ends.</summary>
|
||||
public static Task LockAsync(HubDbContext db, string customer, CancellationToken cancellationToken) =>
|
||||
db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({customer}, 0))", cancellationToken);
|
||||
|
||||
/// <summary>Finds or creates the customer and the license record, and marks the license as seen now.</summary>
|
||||
public static async Task<(Customer Customer, LicenseRecord Record)> EnsureAsync(
|
||||
HubDbContext db, License license, LicenseRecord? record, DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == license.Customer, cancellationToken).ConfigureAwait(false);
|
||||
if (customer is null)
|
||||
{
|
||||
customer = new Customer { Name = license.Customer, CreatedAt = now };
|
||||
db.Customers.Add(customer);
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
record = new LicenseRecord
|
||||
{
|
||||
LicenseId = license.LicenseId, CustomerId = customer.Id, Plan = license.Plan.ToString(), IssuedAt = license.IssuedAt.UtcDateTime,
|
||||
ExpiresAt = license.ExpiresAt.UtcDateTime, MaxInstances = license.MaxInstances, FirstSeenAt = now, LastSeenAt = now,
|
||||
};
|
||||
db.Licenses.Add(record);
|
||||
}
|
||||
|
||||
record.LastSeenAt = now;
|
||||
return (customer, record);
|
||||
}
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ public static class FindingsEndpoint
|
|||
// instance (checked against the license's limit) must not race. Everything read before this point is re-read.
|
||||
var now = time.GetUtcNow().UtcDateTime;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({license.Customer}, 0))", cancellationToken).ConfigureAwait(false);
|
||||
await CustomerRegistration.LockAsync(db, license.Customer, cancellationToken).ConfigureAwait(false);
|
||||
record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false);
|
||||
if (record?.RevokedAt is not null)
|
||||
{
|
||||
|
|
@ -104,25 +104,8 @@ public static class FindingsEndpoint
|
|||
return Results.Ok(new SubmissionReceipt(submission.SubmissionId, Duplicate: true, ActiveFindings: -1));
|
||||
}
|
||||
|
||||
var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == license.Customer, cancellationToken).ConfigureAwait(false);
|
||||
if (customer is null)
|
||||
{
|
||||
customer = new Customer { Name = license.Customer, CreatedAt = now };
|
||||
db.Customers.Add(customer);
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
record = new LicenseRecord
|
||||
{
|
||||
LicenseId = license.LicenseId, CustomerId = customer.Id, Plan = license.Plan.ToString(), IssuedAt = license.IssuedAt.UtcDateTime,
|
||||
ExpiresAt = license.ExpiresAt.UtcDateTime, MaxInstances = license.MaxInstances, FirstSeenAt = now, LastSeenAt = now,
|
||||
};
|
||||
db.Licenses.Add(record);
|
||||
}
|
||||
|
||||
record.LastSeenAt = now;
|
||||
Customer customer;
|
||||
(customer, record) = await CustomerRegistration.EnsureAsync(db, license, record, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var instance = await db.Instances.SingleOrDefaultAsync(i => i.CustomerId == customer.Id && i.InstanceKey == submission.Instance.InstanceKey, cancellationToken).ConfigureAwait(false);
|
||||
if (instance is null)
|
||||
|
|
@ -233,5 +216,5 @@ public static class FindingsEndpoint
|
|||
return seen.Count;
|
||||
}
|
||||
|
||||
private static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title);
|
||||
internal static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ public sealed class SupportCase
|
|||
public int CustomerId { get; set; }
|
||||
public Customer? Customer { get; set; }
|
||||
|
||||
/// <summary>The case number in the support system, e.g. "SUP-1234".</summary>
|
||||
/// <summary>The case number, unique per customer. Suggested as SUP-000001, SUP-000002 … (see <see cref="Support.CaseNumbers"/>).</summary>
|
||||
public required string Reference { get; set; }
|
||||
|
||||
public required string Title { get; set; }
|
||||
|
|
@ -105,6 +105,20 @@ public sealed class SupportCase
|
|||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
public List<HubFinding> Findings { get; } = [];
|
||||
|
||||
/// <summary>Who created it: <see cref="Contracts.SupportCaseSources.Staff"/> or <see cref="Contracts.SupportCaseSources.Customer"/>.</summary>
|
||||
public string Source { get; set; } = Contracts.SupportCaseSources.Staff;
|
||||
|
||||
public string? Description { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? ContactEmail { get; set; }
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>The customer's Windows user who created the case in SQLInsight Web.</summary>
|
||||
public string? RequestedBy { get; set; }
|
||||
|
||||
/// <summary>The agent's id for a customer-created case; a retried request returns the same case.</summary>
|
||||
public Guid? ClientRequestId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A vendor employee who may use the portal.</summary>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,16 @@ public sealed class HubDbContext(DbContextOptions<HubDbContext> options) : DbCon
|
|||
modelBuilder.Entity<SupportCase>(e =>
|
||||
{
|
||||
e.HasOne(c => c.Customer).WithMany(c => c.SupportCases).HasForeignKey(c => c.CustomerId);
|
||||
// Case numbers run per customer, so two customers can both have SUP-000001.
|
||||
e.HasIndex(c => new { c.CustomerId, c.Reference }).IsUnique();
|
||||
e.HasIndex(c => new { c.CustomerId, c.ClientRequestId }).IsUnique().HasFilter("client_request_id IS NOT NULL");
|
||||
e.HasIndex(c => c.CreatedAt);
|
||||
e.Property(c => c.Source).HasMaxLength(16).HasDefaultValue(Contracts.SupportCaseSources.Staff);
|
||||
e.Property(c => c.Description).HasMaxLength(Contracts.SupportCaseRequest.MaxDescription);
|
||||
e.Property(c => c.ContactName).HasMaxLength(Contracts.SupportCaseRequest.MaxContactName);
|
||||
e.Property(c => c.ContactEmail).HasMaxLength(Contracts.SupportCaseRequest.MaxContactEmail);
|
||||
e.Property(c => c.ContactPhone).HasMaxLength(Contracts.SupportCaseRequest.MaxContactPhone);
|
||||
e.Property(c => c.RequestedBy).HasMaxLength(Contracts.SupportCaseRequest.MaxRequestedBy);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<InstanceVersionChange>(e =>
|
||||
|
|
|
|||
586
src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.Designer.cs
generated
Normal file
586
src/SQLInsight.Hub/Data/Migrations/20261001090936_SupportCases.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
// <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("20261001090936_SupportCases")]
|
||||
partial class SupportCases
|
||||
{
|
||||
/// <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>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SQLInsight.Hub.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SupportCases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_support_cases_customer_id",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "client_request_id",
|
||||
table: "support_cases",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "contact_email",
|
||||
table: "support_cases",
|
||||
type: "character varying(320)",
|
||||
maxLength: 320,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "contact_name",
|
||||
table: "support_cases",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "contact_phone",
|
||||
table: "support_cases",
|
||||
type: "character varying(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "description",
|
||||
table: "support_cases",
|
||||
type: "character varying(8000)",
|
||||
maxLength: 8000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "requested_by",
|
||||
table: "support_cases",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "source",
|
||||
table: "support_cases",
|
||||
type: "character varying(16)",
|
||||
maxLength: 16,
|
||||
nullable: false,
|
||||
defaultValue: "Staff");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_support_cases_created_at",
|
||||
table: "support_cases",
|
||||
column: "created_at");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_support_cases_customer_id_client_request_id",
|
||||
table: "support_cases",
|
||||
columns: new[] { "customer_id", "client_request_id" },
|
||||
unique: true,
|
||||
filter: "client_request_id IS NOT NULL");
|
||||
|
||||
// Case numbers were free text before: give any duplicate within a customer a suffix so the unique index can be made.
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE support_cases s SET reference = left(s.reference, 20) || '-' || s.id
|
||||
WHERE EXISTS (SELECT 1 FROM support_cases d WHERE d.customer_id = s.customer_id AND d.reference = s.reference AND d.id < s.id);
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_support_cases_customer_id_reference",
|
||||
table: "support_cases",
|
||||
columns: new[] { "customer_id", "reference" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_support_cases_created_at",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_support_cases_customer_id_client_request_id",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_support_cases_customer_id_reference",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "client_request_id",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "contact_email",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "contact_name",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "contact_phone",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "description",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "requested_by",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "source",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_support_cases_customer_id",
|
||||
table: "support_cases",
|
||||
column: "customer_id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -406,10 +406,29 @@ namespace SQLInsight.Hub.Data.Migrations
|
|||
|
||||
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>("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");
|
||||
|
|
@ -418,6 +437,11 @@ namespace SQLInsight.Hub.Data.Migrations
|
|||
.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");
|
||||
|
|
@ -431,6 +455,19 @@ namespace SQLInsight.Hub.Data.Migrations
|
|||
.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")
|
||||
|
|
@ -439,8 +476,17 @@ namespace SQLInsight.Hub.Data.Migrations
|
|||
b.HasKey("Id")
|
||||
.HasName("pk_support_cases");
|
||||
|
||||
b.HasIndex("CustomerId")
|
||||
.HasDatabaseName("ix_support_cases_customer_id");
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ public sealed class HubServerOptions
|
|||
|
||||
/// <summary>Upgrade insights shared with agents (aggregated, anonymous).</summary>
|
||||
public Insights.InsightOptions Insights { get; set; } = new();
|
||||
|
||||
/// <summary>Support cases from customers: e-mail to SEOSoft when one is created.</summary>
|
||||
public Support.SupportOptions Support { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>How long the hub keeps data it no longer needs. Applied once a day.</summary>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
<PageTitle>Supportsager · SQLInsight Hub</PageTitle>
|
||||
<h1>Åbne supportsager</h1>
|
||||
<p class="lead">Nyeste øverst. Sager, kunden selv har oprettet i SQLInsight Web, er markeret <span class="badge">Kunde</span>. <a href="/portal/rapporter/support">Supportrapport →</a></p>
|
||||
<section class="card">
|
||||
@if (_cases.Count == 0)
|
||||
{
|
||||
|
|
@ -17,12 +18,12 @@
|
|||
@foreach (var c in _cases)
|
||||
{
|
||||
<tr>
|
||||
<td class="mono">@c.Reference</td>
|
||||
<td class="mono nowrap">@c.Reference @if (c.Source == SQLInsight.Contracts.SupportCaseSources.Customer) { <span class="badge">Kunde</span> }</td>
|
||||
<td><a href="/portal/kunder/@c.CustomerId">@c.Customer!.Name</a></td>
|
||||
<td>@c.Title</td>
|
||||
<td>@c.Title<CaseDetails Case="c" /></td>
|
||||
<td class="num">@c.Findings.Count</td>
|
||||
<td>@c.Findings.Count(f => f.ResolvedAt == null)</td>
|
||||
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
|
||||
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC</td>
|
||||
<td><form method="post" action="/portal/actions/cases/@c.Id/close"><AntiforgeryToken /><button type="submit" class="theme-toggle">Luk</button></form></td>
|
||||
</tr>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,12 +17,30 @@
|
|||
<section class="card">
|
||||
<div class="card-head"><h2>Instanser</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Instans</th><th>Version</th><th>Agent</th><th>Seneste data</th></tr></thead>
|
||||
<thead><tr><th>Instans</th><th>Aktive fund</th><th>Version</th><th>Agent</th><th>Seneste data</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey))
|
||||
{
|
||||
<tr>
|
||||
<td><strong>@i.InstanceKey</strong><div class="muted">@i.ServerName</div></td>
|
||||
var selected = i.Id == _instance?.Id;
|
||||
<tr class="@(selected ? "selected" : "")" aria-current="@(selected ? "true" : null)">
|
||||
<td><a href="/portal/kunder/@_customer.Id?instans=@i.Id" title="Vis kun fund på @i.InstanceKey"><strong>@i.InstanceKey</strong></a><div class="muted">@i.ServerName</div></td>
|
||||
<td class="nowrap">
|
||||
@if (_counts.TryGetValue(i.Id, out var counts) && counts.Count > 0)
|
||||
{
|
||||
@foreach (var (severity, count) in counts.Where(c => c.Key >= 2).OrderByDescending(c => c.Key))
|
||||
{
|
||||
<Sev Severity="severity" Count="count" /> @(" ")
|
||||
}
|
||||
@if (counts.Where(c => c.Key < 2).Sum(c => c.Value) is var minor and > 0)
|
||||
{
|
||||
<span class="muted">+@minor lav/info</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="muted">Ingen</span>
|
||||
}
|
||||
</td>
|
||||
<td>@i.Release<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel · @i.Edition</div></td>
|
||||
<td>@i.AgentVersion</td>
|
||||
<td class="nowrap @(i.LastSubmissionAt < Queries.Now.AddHours(-1) ? "bad" : "")">@(i.LastSubmissionAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) UTC</td>
|
||||
|
|
@ -33,8 +51,14 @@
|
|||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Aktive fund</h2></div>
|
||||
<FindingTable Rows="_findings" ShowCustomer="false" />
|
||||
<div class="card-head">
|
||||
<h2>Aktive fund@(_instance is null ? "" : $" · {_instance.InstanceKey}")</h2>
|
||||
@if (_instance is not null)
|
||||
{
|
||||
<a href="/portal/kunder/@_customer.Id">Vis alle instanser</a>
|
||||
}
|
||||
</div>
|
||||
<FindingTable Rows="_findings" ShowCustomer="false" ShowInstance="_instance is null" />
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
|
@ -46,25 +70,55 @@
|
|||
else
|
||||
{
|
||||
<table>
|
||||
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th></tr></thead>
|
||||
<thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
|
||||
{
|
||||
<tr><td class="mono">@c.Reference</td><td>@c.Title</td><td>@(c.IsOpen ? "Åben" : "Lukket")</td><td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td></tr>
|
||||
<tr>
|
||||
<td class="mono">@c.Reference</td>
|
||||
<td>@c.Title<CaseDetails Case="c" /></td>
|
||||
<td>@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")</td>
|
||||
<td>@(c.IsOpen ? "Åben" : "Lukket")</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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
<h3>Ny sag</h3>
|
||||
<form method="post" action="/portal/actions/customers/@_customer.Id/cases" class="inline-form">
|
||||
<AntiforgeryToken />
|
||||
<input name="reference" value="@_nextReference" aria-label="Sagsnummer" title="Foreslået sagsnummer; kan rettes" />
|
||||
<input name="title" placeholder="Titel" required maxlength="200" />
|
||||
<input name="description" placeholder="Beskrivelse (valgfri)" maxlength="8000" />
|
||||
<button type="submit" class="primary">Opret sag</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
/// <summary>Show only this instance's findings. An id of another customer's instance is ignored (all are shown).</summary>
|
||||
[SupplyParameterFromQuery(Name = "instans")] public int? Instans { get; set; }
|
||||
|
||||
private Customer? _customer;
|
||||
private Instance? _instance;
|
||||
private IReadOnlyList<FindingRow> _findings = [];
|
||||
private IReadOnlyDictionary<int, IReadOnlyDictionary<int, int>> _counts = new Dictionary<int, IReadOnlyDictionary<int, int>>();
|
||||
private string _nextReference = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_customer = await Queries.CustomerAsync(Id);
|
||||
_findings = _customer is null ? [] : await Queries.FindingsAsync(Id);
|
||||
if (_customer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_instance = _customer.Instances.FirstOrDefault(i => i.Id == Instans);
|
||||
_findings = await Queries.FindingsAsync(Id, instanceId: _instance?.Id);
|
||||
_counts = await Queries.ActiveFindingCountsAsync(Id);
|
||||
_nextReference = await Queries.NextCaseReferenceAsync(Id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
}
|
||||
<form method="post" action="/portal/actions/findings/@_finding.Id/case" class="inline-form">
|
||||
<AntiforgeryToken />
|
||||
<input name="reference" placeholder="Sagsnummer, fx SUP-1234" required />
|
||||
<input name="reference" value="@_nextReference" aria-label="Sagsnummer" title="Foreslået sagsnummer; kan rettes" />
|
||||
<input name="title" placeholder="Titel (valgfri)" />
|
||||
<button type="submit" class="primary">Opret sag</button>
|
||||
</form>
|
||||
|
|
@ -60,9 +60,14 @@
|
|||
@code {
|
||||
[Parameter] public long Id { get; set; }
|
||||
private HubFinding? _finding;
|
||||
private string _nextReference = string.Empty;
|
||||
|
||||
private IReadOnlyList<SQLInsight.Contracts.EvidenceDto> Evidence =>
|
||||
System.Text.Json.JsonSerializer.Deserialize<List<SQLInsight.Contracts.EvidenceDto>>(_finding!.EvidenceJson, System.Text.Json.JsonSerializerOptions.Web) ?? [];
|
||||
|
||||
protected override async Task OnInitializedAsync() => _finding = await Queries.FindingAsync(Id);
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_finding = await Queries.FindingAsync(Id);
|
||||
_nextReference = _finding is null ? string.Empty : await Queries.NextCaseReferenceAsync(_finding.Instance!.CustomerId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
123
src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor
Normal file
123
src/SQLInsight.Hub/Portal/Pages/SupportReportPage.razor
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
@page "/portal/rapporter/support"
|
||||
@attribute [Authorize]
|
||||
@using SQLInsight.Hub.Support
|
||||
@inject PortalQueries Queries
|
||||
|
||||
<PageTitle>Supportrapport · SQLInsight Hub</PageTitle>
|
||||
@if (_report is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
<h1>Supportrapport</h1>
|
||||
<p class="lead">
|
||||
Sager oprettet og lukket i perioden, åbne sager nu og hvor hurtigt sagerne blev løst.
|
||||
Løsningstid er tiden fra oprettelse til lukning for sager lukket i perioden. Tider er UTC.
|
||||
</p>
|
||||
|
||||
<form method="get" action="/portal/rapporter/support" class="inline-form report-filter">
|
||||
<label>Fra <input type="date" name="fra" value="@_report.From.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)" /></label>
|
||||
<label>Til og med <input type="date" name="til" value="@_report.To.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)" /></label>
|
||||
<label>Kunde
|
||||
<select name="kunde">
|
||||
<option value="">Alle kunder</option>
|
||||
@foreach (var (id, name) in _customers)
|
||||
{
|
||||
<option value="@id" selected="@(id == Kunde)">@name</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="primary">Vis</button>
|
||||
<a class="theme-toggle" href="@CsvUrl">Hent CSV</a>
|
||||
</form>
|
||||
|
||||
<section class="kpis">
|
||||
<div class="kpi"><div><strong>@_report.Total.Created</strong><span>oprettet</span></div></div>
|
||||
<div class="kpi"><div><strong>@_report.Total.Closed</strong><span>lukket</span></div></div>
|
||||
<div class="kpi"><div><strong>@_report.Total.OpenNow</strong><span>åbne nu</span></div></div>
|
||||
<div class="kpi"><div><strong>@SupportReport.Duration(_report.Total.MedianResolution)</strong><span>median løsningstid</span></div></div>
|
||||
<div class="kpi"><div><strong>@SupportReport.Duration(_report.Total.AverageResolution)</strong><span>gennemsnitlig løsningstid</span></div></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Pr. kunde</h2></div>
|
||||
@if (_report.Customers.Count == 0)
|
||||
{
|
||||
<p class="empty">Ingen sager i perioden.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th><th class="num">Oprettet</th><th class="num">Lukket</th><th class="num">Åbne nu</th>
|
||||
<th>Gennemsnit</th><th>Median</th><th>Ældste åbne</th><th class="num">Oprettet af kunden</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _report.Customers.Append(_report.Total))
|
||||
{
|
||||
<tr class="@(c.CustomerId is null ? "total" : "")">
|
||||
<td>@if (c.CustomerId is { } id) { <a href="/portal/kunder/@id">@c.Customer</a> } else { <strong>@c.Customer</strong> }</td>
|
||||
<td class="num">@c.Created</td>
|
||||
<td class="num">@c.Closed</td>
|
||||
<td class="num">@c.OpenNow</td>
|
||||
<td class="nowrap">@SupportReport.Duration(c.AverageResolution)</td>
|
||||
<td class="nowrap">@SupportReport.Duration(c.MedianResolution)</td>
|
||||
<td class="nowrap">@SupportReport.Duration(c.OldestOpenAge)</td>
|
||||
<td class="num">@(c.CustomerCreatedShare is { } share ? share.ToString("P0", Danish) : "–")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Sager i perioden</h2></div>
|
||||
@if (_report.Cases.Count == 0)
|
||||
{
|
||||
<p class="empty">Ingen.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var c in _report.Cases)
|
||||
{
|
||||
<tr>
|
||||
<td class="mono nowrap">@c.Reference</td>
|
||||
<td><a href="/portal/kunder/@c.CustomerId">@c.Customer</a></td>
|
||||
<td>@c.Title</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.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
|
||||
<td class="nowrap">@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
|
||||
|
||||
[SupplyParameterFromQuery] public DateTime? Fra { get; set; }
|
||||
[SupplyParameterFromQuery] public DateTime? Til { get; set; }
|
||||
[SupplyParameterFromQuery] public int? Kunde { get; set; }
|
||||
|
||||
private SupportReportResult? _report;
|
||||
private IReadOnlyList<(int Id, string Name)> _customers = [];
|
||||
|
||||
private string CsvUrl =>
|
||||
$"/portal/actions/reports/support.csv?fra={_report!.From:yyyy-MM-dd}&til={_report.To.AddDays(-1):yyyy-MM-dd}{(Kunde is { } k ? $"&kunde={k}" : "")}";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var (from, to) = SupportReportPeriod.Resolve(Fra, Til, Queries.Now);
|
||||
_report = await Queries.SupportReportAsync(from, to, Kunde);
|
||||
_customers = await Queries.CustomerNamesAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Authentication;
|
|||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SQLInsight.Contracts;
|
||||
using SQLInsight.Hub.Data;
|
||||
using SQLInsight.Hub.Support;
|
||||
|
||||
namespace SQLInsight.Hub.Portal;
|
||||
|
||||
|
|
@ -25,6 +27,8 @@ public static class PortalEndpoints
|
|||
|
||||
var portal = app.MapGroup("/portal/actions").RequireAuthorization();
|
||||
portal.MapPost("/findings/{id:long}/case", LinkCaseAsync);
|
||||
portal.MapPost("/customers/{id:int}/cases", CreateCaseAsync);
|
||||
portal.MapGet("/reports/support.csv", SupportReportCsvAsync);
|
||||
portal.MapPost("/findings/{id:long}/unlink", async (long id, HubDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var finding = await db.Findings.FindAsync([id], ct);
|
||||
|
|
@ -101,21 +105,56 @@ public static class PortalEndpoints
|
|||
|
||||
finding.SupportCaseId = supportCase.Id;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(reference))
|
||||
else
|
||||
{
|
||||
var supportCase = new SupportCase
|
||||
// An empty case number gets the customer's next SUP-number.
|
||||
var (supportCase, error) = await SupportCaseStore.AddAsync(db, new SupportCase
|
||||
{
|
||||
CustomerId = finding.Instance!.CustomerId,
|
||||
Reference = reference.Trim(),
|
||||
Reference = reference ?? string.Empty,
|
||||
Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(),
|
||||
CreatedAt = time.GetUtcNow().UtcDateTime,
|
||||
};
|
||||
db.SupportCases.Add(supportCase);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}, ct);
|
||||
if (supportCase is null)
|
||||
{
|
||||
return Results.BadRequest(error);
|
||||
}
|
||||
|
||||
finding.SupportCaseId = supportCase.Id;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Redirect($"/portal/fund/{id}");
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCaseAsync(int id, [FromForm] string? reference, [FromForm] string title, [FromForm] string? description,
|
||||
HubDbContext db, TimeProvider time, CancellationToken ct)
|
||||
{
|
||||
if (!await db.Customers.AnyAsync(c => c.Id == id, ct))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) || title.Length > SupportCaseRequest.MaxTitle)
|
||||
{
|
||||
return Results.BadRequest($"Titel skal udfyldes (højst {SupportCaseRequest.MaxTitle} tegn).");
|
||||
}
|
||||
|
||||
var (_, error) = await SupportCaseStore.AddAsync(db, new SupportCase
|
||||
{
|
||||
CustomerId = id,
|
||||
Reference = reference ?? string.Empty,
|
||||
Title = title.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim()[..Math.Min(description.Trim().Length, SupportCaseRequest.MaxDescription)],
|
||||
CreatedAt = time.GetUtcNow().UtcDateTime,
|
||||
}, ct);
|
||||
return error is null ? Results.Redirect($"/portal/kunder/{id}") : Results.BadRequest(error);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SupportReportCsvAsync(DateTime? fra, DateTime? til, int? kunde, PortalQueries queries, CancellationToken ct)
|
||||
{
|
||||
var (from, to) = SupportReportPeriod.Resolve(fra, til, queries.Now);
|
||||
var report = await queries.SupportReportAsync(from, to, kunde, ct);
|
||||
return Results.File(SupportReport.Csv(report), "text/csv; charset=utf-8", $"supportsager-{from:yyyy-MM-dd}-{to.AddDays(-1):yyyy-MM-dd}.csv");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,9 +63,10 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time)
|
|||
return db.Instances.CountAsync(i => i.LastSubmissionAt < limit, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FindingRow>> FindingsAsync(int? customerId = null, int minSeverity = 0, CancellationToken ct = default) =>
|
||||
public async Task<IReadOnlyList<FindingRow>> FindingsAsync(int? customerId = null, int minSeverity = 0, int? instanceId = null, CancellationToken ct = default) =>
|
||||
await db.Findings
|
||||
.Where(f => f.ResolvedAt == null && f.Severity >= minSeverity && (customerId == null || f.Instance!.CustomerId == customerId))
|
||||
.Where(f => instanceId == null || f.InstanceId == instanceId)
|
||||
.OrderByDescending(f => f.Severity).ThenBy(f => f.FirstSeenAt)
|
||||
.Select(f => new FindingRow(f.Id, f.Instance!.Customer!.Name, f.Instance.CustomerId, f.Instance.InstanceKey, f.RuleId, f.Subject, f.Severity, f.Title, f.FirstSeenAt,
|
||||
f.SupportCase == null ? null : f.SupportCase.Reference))
|
||||
|
|
@ -79,9 +80,43 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time)
|
|||
db.Findings.Include(f => f.Instance!).ThenInclude(i => i.Customer!).ThenInclude(c => c.SupportCases).Include(f => f.SupportCase)
|
||||
.AsSplitQuery().SingleOrDefaultAsync(f => f.Id == id, ct);
|
||||
|
||||
/// <summary>Open cases, newest first: a customer's new case is what support should see first.</summary>
|
||||
public async Task<IReadOnlyList<SupportCase>> OpenCasesAsync(CancellationToken ct = default) =>
|
||||
await db.SupportCases.Include(c => c.Customer).Include(c => c.Findings).Where(c => c.IsOpen)
|
||||
.OrderBy(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false);
|
||||
.OrderByDescending(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
/// <summary>Active findings per instance of a customer and severity: instance id → severity → count.</summary>
|
||||
public async Task<IReadOnlyDictionary<int, IReadOnlyDictionary<int, int>>> ActiveFindingCountsAsync(int customerId, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await db.Findings
|
||||
.Where(f => f.ResolvedAt == null && f.Instance!.CustomerId == customerId)
|
||||
.GroupBy(f => new { f.InstanceId, f.Severity })
|
||||
.Select(g => new { g.Key.InstanceId, g.Key.Severity, Count = g.Count() })
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
return rows.GroupBy(r => r.InstanceId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyDictionary<int, int>)g.ToDictionary(r => r.Severity, r => r.Count));
|
||||
}
|
||||
|
||||
public Task<string> NextCaseReferenceAsync(int customerId, CancellationToken ct = default) =>
|
||||
Support.SupportCaseStore.NextReferenceAsync(db, customerId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<(int Id, string Name)>> CustomerNamesAsync(CancellationToken ct = default) =>
|
||||
(await db.Customers.OrderBy(c => c.Name).Select(c => new { c.Id, c.Name }).ToListAsync(ct).ConfigureAwait(false))
|
||||
.Select(c => (c.Id, c.Name)).ToList();
|
||||
|
||||
/// <summary>The support report for [from, to) UTC, for all customers or one.</summary>
|
||||
public async Task<Support.SupportReportResult> SupportReportAsync(DateTime from, DateTime to, int? customerId, CancellationToken ct = default)
|
||||
{
|
||||
var cases = await db.SupportCases
|
||||
.Where(c => customerId == null || c.CustomerId == customerId)
|
||||
.Where(c => c.IsOpen || (c.CreatedAt >= from && c.CreatedAt < to) || (c.ClosedAt >= from && c.ClosedAt < to))
|
||||
.Select(c => new { c.Id, c.CustomerId, Customer = c.Customer!.Name, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen, c.ClosedAt })
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
// A closed case without a closing time (closed before it was recorded) counts as closed when it was created.
|
||||
return Support.SupportReport.Build(
|
||||
cases.Select(c => new Support.SupportReportCase(c.Id, c.CustomerId, c.Customer, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen ? null : c.ClosedAt ?? c.CreatedAt)),
|
||||
from, to, Now);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LicenseRecord>> LicensesAsync(CancellationToken ct = default) =>
|
||||
await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false);
|
||||
|
|
|
|||
19
src/SQLInsight.Hub/Portal/Shared/CaseDetails.razor
Normal file
19
src/SQLInsight.Hub/Portal/Shared/CaseDetails.razor
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@* Contact and description of a case, folded away: shown under the title in case lists. *@
|
||||
@if (Case.ContactName is not null || Case.Description is not null)
|
||||
{
|
||||
<details class="case-details">
|
||||
<summary class="muted">@(Case.ContactName is { } name ? $"{name}{(Case.RequestedBy is { } user ? $" ({user})" : "")}" : "Beskrivelse")</summary>
|
||||
@if (Case.ContactEmail is { } email)
|
||||
{
|
||||
<div><a href="mailto:@email">@email</a>@(Case.ContactPhone is { } phone ? $" · {phone}" : "")</div>
|
||||
}
|
||||
@if (Case.Description is { } description)
|
||||
{
|
||||
<p class="case-description">@description</p>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public SupportCase Case { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -5,14 +5,14 @@
|
|||
else
|
||||
{
|
||||
<table>
|
||||
<thead><tr><th>Alvor</th>@if (ShowCustomer) { <th>Kunde</th> }<th>Instans</th><th>Fund</th><th>Regel</th><th>Sag</th><th>Siden</th></tr></thead>
|
||||
<thead><tr><th>Alvor</th>@if (ShowCustomer) { <th>Kunde</th> }@if (ShowInstance) { <th>Instans</th> }<th>Fund</th><th>Regel</th><th>Sag</th><th>Siden</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var f in Rows)
|
||||
{
|
||||
<tr>
|
||||
<td><Sev Severity="f.Severity" /></td>
|
||||
@if (ShowCustomer) { <td><a href="/portal/kunder/@f.CustomerId">@f.Customer</a></td> }
|
||||
<td>@f.Instance</td>
|
||||
@if (ShowInstance) { <td>@f.Instance</td> }
|
||||
<td><a href="/portal/fund/@f.Id">@f.Title</a></td>
|
||||
<td class="mono">@f.RuleId</td>
|
||||
<td>@(f.CaseReference ?? "–")</td>
|
||||
|
|
@ -26,4 +26,5 @@ else
|
|||
@code {
|
||||
[Parameter, EditorRequired] public IReadOnlyList<FindingRow> Rows { get; set; } = [];
|
||||
[Parameter] public bool ShowCustomer { get; set; } = true;
|
||||
[Parameter] public bool ShowInstance { get; set; } = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<NavLink href="/portal/fund">Alle fund</NavLink>
|
||||
<NavLink href="/portal/indsigt">Opgraderinger</NavLink>
|
||||
<NavLink href="/portal/sager">Supportsager</NavLink>
|
||||
<NavLink href="/portal/rapporter/support">Supportrapport</NavLink>
|
||||
<NavLink href="/portal/licenser">Licenser</NavLink>
|
||||
</nav>
|
||||
<div class="support-card">
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ public static class HubProgram
|
|||
builder.Services.AddHostedService<HubRetentionService>();
|
||||
builder.Services.AddScoped<StaffAccounts>();
|
||||
builder.Services.AddScoped<PortalQueries>();
|
||||
builder.Services.AddSingleton<SQLInsight.Hub.Support.SupportNotifier>();
|
||||
builder.Services.AddRazorComponents();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All));
|
||||
|
|
@ -126,6 +127,7 @@ public static class HubProgram
|
|||
app.MapGet("/", () => Results.Redirect("/portal"));
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
FindingsEndpoint.Map(app);
|
||||
CasesEndpoint.Map(app);
|
||||
SQLInsight.Hub.Insights.InsightsEndpoint.Map(app);
|
||||
PortalEndpoints.Map(app);
|
||||
app.MapRazorComponents<PortalApp>();
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@
|
|||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
<PackageReference Include="MailKit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
35
src/SQLInsight.Hub/Support/CaseNumbers.cs
Normal file
35
src/SQLInsight.Hub/Support/CaseNumbers.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SQLInsight.Hub.Support;
|
||||
|
||||
/// <summary>Case numbers SUP-000001, SUP-000002 … running per customer.</summary>
|
||||
public static partial class CaseNumbers
|
||||
{
|
||||
public const string Prefix = "SUP-";
|
||||
|
||||
/// <summary>
|
||||
/// The number after the highest SUP-<digits> among <paramref name="existing"/>. Other references (typed by hand,
|
||||
/// e.g. from an older system) are ignored, so they never block or reset the sequence.
|
||||
/// </summary>
|
||||
public static string Next(IEnumerable<string> existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(existing);
|
||||
long highest = 0;
|
||||
foreach (var reference in existing)
|
||||
{
|
||||
var match = Number().Match(reference ?? string.Empty);
|
||||
if (match.Success && long.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var n) && n > highest)
|
||||
{
|
||||
highest = n;
|
||||
}
|
||||
}
|
||||
|
||||
return Format(highest + 1);
|
||||
}
|
||||
|
||||
public static string Format(long number) => Prefix + number.ToString("D6", CultureInfo.InvariantCulture);
|
||||
|
||||
[GeneratedRegex(@"^\s*SUP-(\d{1,18})\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Number();
|
||||
}
|
||||
74
src/SQLInsight.Hub/Support/SupportCaseStore.cs
Normal file
74
src/SQLInsight.Hub/Support/SupportCaseStore.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using SQLInsight.Contracts;
|
||||
using SQLInsight.Hub.Data;
|
||||
|
||||
namespace SQLInsight.Hub.Support;
|
||||
|
||||
/// <summary>Creates support cases with a case number that is unique per customer.</summary>
|
||||
public static class SupportCaseStore
|
||||
{
|
||||
private const int Attempts = 5;
|
||||
|
||||
/// <summary>The number the next case for the customer gets unless another is typed: SUP-000001, SUP-000002 …</summary>
|
||||
public static async Task<string> NextReferenceAsync(HubDbContext db, int customerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
var references = await db.SupportCases.Where(c => c.CustomerId == customerId).Select(c => c.Reference).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return CaseNumbers.Next(references);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="draft"/> and saves it. An empty <see cref="SupportCase.Reference"/> gets the next free number;
|
||||
/// if someone else took it meanwhile, the next one is tried. A typed reference that already exists for the customer
|
||||
/// is refused. Inside a transaction EF Core rolls back to a savepoint on failure, so retrying is safe there too.
|
||||
/// </summary>
|
||||
/// <returns>The saved case, or the reason it was not saved.</returns>
|
||||
public static async Task<(SupportCase? Case, string? Error)> AddAsync(HubDbContext db, SupportCase draft, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
ArgumentNullException.ThrowIfNull(draft);
|
||||
draft.Reference = draft.Reference?.Trim() ?? string.Empty;
|
||||
var typed = draft.Reference.Length > 0;
|
||||
if (typed && draft.Reference.Length > 32)
|
||||
{
|
||||
return (null, "Sagsnummeret må højst være 32 tegn.");
|
||||
}
|
||||
|
||||
for (var attempt = 0; attempt < Attempts; attempt++)
|
||||
{
|
||||
if (!typed)
|
||||
{
|
||||
draft.Reference = await NextReferenceAsync(db, draft.CustomerId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (await db.SupportCases.AnyAsync(c => c.CustomerId == draft.CustomerId && c.Reference == draft.Reference, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return (null, $"Sagsnummeret {draft.Reference} findes allerede for kunden.");
|
||||
}
|
||||
|
||||
db.SupportCases.Add(draft);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (draft, null);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation })
|
||||
{
|
||||
db.Entry(draft).State = EntityState.Detached;
|
||||
if (typed)
|
||||
{
|
||||
return (null, $"Sagsnummeret {draft.Reference} findes allerede for kunden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (null, "Der kunne ikke tildeles et sagsnummer. Prøv igen.");
|
||||
}
|
||||
|
||||
public static SupportCaseDto ToDto(SupportCase c)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(c);
|
||||
return new SupportCaseDto(c.Reference, c.Title, c.IsOpen, Utc(c.CreatedAt), c.ClosedAt is { } closed ? Utc(closed) : null, c.Source, c.ClientRequestId);
|
||||
}
|
||||
|
||||
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
|
||||
}
|
||||
134
src/SQLInsight.Hub/Support/SupportNotifier.cs
Normal file
134
src/SQLInsight.Hub/Support/SupportNotifier.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using SQLInsight.Hub.Data;
|
||||
|
||||
namespace SQLInsight.Hub.Support;
|
||||
|
||||
/// <summary>SQLInsight:Hub:Support: what happens when a customer creates a support case.</summary>
|
||||
public sealed class SupportOptions
|
||||
{
|
||||
public SupportNotifyOptions Notify { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>E-mail to SEOSoft's support when a customer creates a case in SQLInsight Web.</summary>
|
||||
public sealed class SupportNotifyOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? SmtpHost { get; set; }
|
||||
public int SmtpPort { get; set; } = 587;
|
||||
|
||||
/// <summary>STARTTLS on the port (Microsoft 365: smtp.office365.com:587). False: TLS from the start (port 465).</summary>
|
||||
public bool StartTls { get; set; } = true;
|
||||
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? From { get; set; }
|
||||
public IList<string> To { get; } = [];
|
||||
|
||||
/// <summary>The portal's public address, for a link to the case in the mail (e.g. https://hub.sqlinsight.seosoft.dk/).</summary>
|
||||
public Uri? PortalUrl { get; set; }
|
||||
|
||||
public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(SmtpHost) && !string.IsNullOrWhiteSpace(From) && To.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mails support about a new customer case. A mail that cannot be sent is logged and otherwise ignored: the case is
|
||||
/// saved and visible in the portal either way.
|
||||
/// </summary>
|
||||
public sealed partial class SupportNotifier(IOptions<HubServerOptions> options, ILogger<SupportNotifier> logger)
|
||||
{
|
||||
public async Task NotifyAsync(SupportCase supportCase, string customer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(supportCase);
|
||||
var settings = options.Value.Support.Notify;
|
||||
if (!settings.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient { Timeout = 20_000 };
|
||||
await client.ConnectAsync(settings.SmtpHost!, settings.SmtpPort, settings.StartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.SslOnConnect, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(settings.UserName))
|
||||
{
|
||||
// A password can never do OAuth, and Microsoft 365 rejects PLAIN: prefer LOGIN where it is offered.
|
||||
client.AuthenticationMechanisms.Remove("XOAUTH2");
|
||||
if (client.AuthenticationMechanisms.Contains("LOGIN"))
|
||||
{
|
||||
client.AuthenticationMechanisms.Remove("PLAIN");
|
||||
}
|
||||
|
||||
await client.AuthenticateAsync(settings.UserName, settings.Password ?? string.Empty, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await client.SendAsync(Build(settings, supportCase, customer), cancellationToken).ConfigureAwait(false);
|
||||
await client.DisconnectAsync(true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogMailFailed(logger, ex, supportCase.Reference, customer);
|
||||
}
|
||||
}
|
||||
|
||||
internal static MimeMessage Build(SupportNotifyOptions settings, SupportCase c, string customer)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(MailboxAddress.Parse(settings.From!));
|
||||
foreach (var to in settings.To)
|
||||
{
|
||||
message.To.Add(MailboxAddress.Parse(to));
|
||||
}
|
||||
|
||||
if (c.ContactEmail is { Length: > 0 } email && MailboxAddress.TryParse(email, out var replyTo))
|
||||
{
|
||||
replyTo.Name = c.ContactName;
|
||||
message.ReplyTo.Add(replyTo);
|
||||
}
|
||||
|
||||
message.Subject = $"Ny supportsag {c.Reference} fra {customer}: {c.Title}";
|
||||
var lines = new List<(string Label, string? Value)>
|
||||
{
|
||||
("Kunde", customer),
|
||||
("Sag", c.Reference),
|
||||
("Titel", c.Title),
|
||||
("Kontakt", c.ContactName),
|
||||
("E-mail", c.ContactEmail),
|
||||
("Telefon", c.ContactPhone),
|
||||
("Oprettet af", c.RequestedBy),
|
||||
};
|
||||
var text = new StringBuilder();
|
||||
var html = new StringBuilder("<div style=\"font-family:Segoe UI,Arial,sans-serif;font-size:14px\"><table>");
|
||||
foreach (var (label, value) in lines.Where(l => !string.IsNullOrWhiteSpace(l.Value)))
|
||||
{
|
||||
text.Append(label).Append(": ").Append(value).Append('\n');
|
||||
html.Append("<tr><th style=\"text-align:left;padding-right:12px\">").Append(WebUtility.HtmlEncode(label)).Append("</th><td>")
|
||||
.Append(WebUtility.HtmlEncode(value)).Append("</td></tr>");
|
||||
}
|
||||
|
||||
html.Append("</table>");
|
||||
if (!string.IsNullOrWhiteSpace(c.Description))
|
||||
{
|
||||
text.Append('\n').Append(c.Description).Append('\n');
|
||||
html.Append("<p style=\"white-space:pre-wrap\">").Append(WebUtility.HtmlEncode(c.Description)).Append("</p>");
|
||||
}
|
||||
|
||||
if (settings.PortalUrl is { } portal)
|
||||
{
|
||||
var link = new Uri(portal, $"portal/kunder/{c.CustomerId}");
|
||||
text.Append('\n').Append(link).Append('\n');
|
||||
html.Append("<p><a href=\"").Append(WebUtility.HtmlEncode(link.ToString())).Append("\">Åbn kunden i SQLInsight Hub</a></p>");
|
||||
}
|
||||
|
||||
html.Append("<p style=\"color:#667085;font-size:12px\">Sendt af SQLInsight Hub. Svar på mailen går til kunden.</p></div>");
|
||||
message.Body = new BodyBuilder { TextBody = text.ToString(), HtmlBody = html.ToString() }.ToMessageBody();
|
||||
return message;
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not mail support about case {Reference} from {Customer}")]
|
||||
private static partial void LogMailFailed(ILogger logger, Exception exception, string reference, string customer);
|
||||
}
|
||||
153
src/SQLInsight.Hub/Support/SupportReport.cs
Normal file
153
src/SQLInsight.Hub/Support/SupportReport.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using SQLInsight.Contracts;
|
||||
|
||||
namespace SQLInsight.Hub.Support;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
public TimeSpan? ResolutionTime => ClosedAt - CreatedAt;
|
||||
}
|
||||
|
||||
/// <summary>Key figures for one customer (or, for the total row, all customers).</summary>
|
||||
public sealed record SupportReportCustomer(
|
||||
int? CustomerId,
|
||||
string Customer,
|
||||
int Created,
|
||||
int Closed,
|
||||
int OpenNow,
|
||||
TimeSpan? AverageResolution,
|
||||
TimeSpan? MedianResolution,
|
||||
TimeSpan? OldestOpenAge,
|
||||
double? CustomerCreatedShare);
|
||||
|
||||
public sealed record SupportReportResult(
|
||||
DateTime From,
|
||||
DateTime To,
|
||||
IReadOnlyList<SupportReportCustomer> Customers,
|
||||
SupportReportCustomer Total,
|
||||
IReadOnlyList<SupportReportCase> Cases);
|
||||
|
||||
/// <summary>The report period from the form: whole days, "til" included. Default: the last 90 days up to today.</summary>
|
||||
public static class SupportReportPeriod
|
||||
{
|
||||
public const int DefaultDays = 90;
|
||||
|
||||
/// <returns>[From, To) in UTC.</returns>
|
||||
public static (DateTime From, DateTime To) Resolve(DateTime? from, DateTime? to, DateTime now)
|
||||
{
|
||||
var end = (to?.Date ?? now.Date).AddDays(1);
|
||||
var start = from?.Date ?? end.AddDays(-DefaultDays);
|
||||
if (start >= end)
|
||||
{
|
||||
(start, end) = (end.AddDays(-1), start.AddDays(1));
|
||||
}
|
||||
|
||||
return (DateTime.SpecifyKind(start, DateTimeKind.Utc), DateTime.SpecifyKind(end, DateTimeKind.Utc));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The support report: how many cases were created and closed in a period, how many are open now, and how fast they
|
||||
/// were solved. Resolution time is ClosedAt − CreatedAt of the cases closed in the period.
|
||||
/// </summary>
|
||||
public static class SupportReport
|
||||
{
|
||||
/// <param name="cases">Every case that may be relevant: created or closed in the period, or still open.</param>
|
||||
/// <param name="from">Start of the period (inclusive, UTC).</param>
|
||||
/// <param name="to">End of the period (exclusive, UTC).</param>
|
||||
/// <param name="now">Now (UTC), for the age of open cases.</param>
|
||||
public static SupportReportResult Build(IEnumerable<SupportReportCase> cases, DateTime from, DateTime to, DateTime now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cases);
|
||||
var all = cases.ToList();
|
||||
bool InPeriod(DateTime t) => t >= from && t < to;
|
||||
|
||||
var listed = all
|
||||
.Where(c => InPeriod(c.CreatedAt) || c.ClosedAt is { } closed && InPeriod(closed))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
var customers = all
|
||||
.GroupBy(c => (c.CustomerId, c.Customer))
|
||||
.Select(g => Figures(g.Key.CustomerId, g.Key.Customer, g.ToList(), InPeriod, now))
|
||||
.Where(f => f.Created > 0 || f.Closed > 0 || f.OpenNow > 0)
|
||||
.OrderByDescending(f => f.OpenNow).ThenByDescending(f => f.Created).ThenBy(f => f.Customer, StringComparer.CurrentCulture)
|
||||
.ToList();
|
||||
|
||||
return new SupportReportResult(from, to, customers, Figures(null, "I alt", all, InPeriod, now), listed);
|
||||
}
|
||||
|
||||
private static SupportReportCustomer Figures(int? customerId, string name, IReadOnlyList<SupportReportCase> cases, Func<DateTime, bool> inPeriod, DateTime now)
|
||||
{
|
||||
var created = cases.Where(c => inPeriod(c.CreatedAt)).ToList();
|
||||
var resolutions = cases.Where(c => c.ClosedAt is { } closed && inPeriod(closed)).Select(c => c.ResolutionTime!.Value).ToList();
|
||||
var open = cases.Where(c => c.ClosedAt is null).ToList();
|
||||
return new SupportReportCustomer(
|
||||
customerId,
|
||||
name,
|
||||
created.Count,
|
||||
resolutions.Count,
|
||||
open.Count,
|
||||
resolutions.Count == 0 ? null : TimeSpan.FromTicks((long)resolutions.Average(r => r.Ticks)),
|
||||
Median(resolutions),
|
||||
open.Count == 0 ? null : now - open.Min(c => c.CreatedAt),
|
||||
created.Count == 0 ? null : (double)created.Count(c => c.Source == SupportCaseSources.Customer) / created.Count);
|
||||
}
|
||||
|
||||
public static TimeSpan? Median(IReadOnlyCollection<TimeSpan> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
var middle = sorted.Count / 2;
|
||||
return sorted.Count % 2 == 1 ? sorted[middle] : TimeSpan.FromTicks((sorted[middle - 1].Ticks + sorted[middle].Ticks) / 2);
|
||||
}
|
||||
|
||||
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
|
||||
|
||||
/// <summary>Readable duration: "45 min", "6,5 t", "3,2 dage".</summary>
|
||||
public static string Duration(TimeSpan? value) => value switch
|
||||
{
|
||||
null => "–",
|
||||
{ TotalMinutes: < 60 } v => $"{Math.Max(0, (int)Math.Round(v.TotalMinutes))} min",
|
||||
{ TotalHours: < 48 } v => $"{v.TotalHours.ToString("0.#", Danish)} t",
|
||||
{ } v => $"{v.TotalDays.ToString("0.#", Danish)} dage",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The cases as CSV for Excel in Danish: UTF-8 with BOM and semicolons. Times in UTC; resolution time in hours.
|
||||
/// </summary>
|
||||
public static byte[] Csv(SupportReportResult report)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
var csv = new StringBuilder();
|
||||
csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Løsningstid (timer)\r\n");
|
||||
foreach (var c in report.Cases)
|
||||
{
|
||||
csv.AppendJoin(';',
|
||||
Field(c.Reference),
|
||||
Field(c.Customer),
|
||||
Field(c.Title),
|
||||
Field(c.Source == SupportCaseSources.Customer ? "Kunde" : "SEOSoft"),
|
||||
c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||
c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty);
|
||||
csv.Append("\r\n");
|
||||
}
|
||||
|
||||
return [.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes(csv.ToString())];
|
||||
}
|
||||
|
||||
/// <summary>Quoted when needed; a leading =, +, - or @ is defused so Excel does not run it as a formula.</summary>
|
||||
private static string Field(string value)
|
||||
{
|
||||
var text = value.Length > 0 && "=+-@".Contains(value[0], StringComparison.Ordinal) ? "'" + value : value;
|
||||
return text.IndexOfAny([';', '"', '\r', '\n']) >= 0 ? "\"" + text.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"" : text;
|
||||
}
|
||||
}
|
||||
|
|
@ -167,3 +167,13 @@ input, select { font: inherit; padding: .5rem .65rem; border: 1px solid var(--bo
|
|||
.nowrap { white-space: nowrap; }
|
||||
td .inline-form { flex-wrap: nowrap; margin: 0; }
|
||||
td .inline-form input { width: 8rem; min-width: 0; }
|
||||
|
||||
/* Support cases: contact and description folded under the title. */
|
||||
.case-details { margin-top: .25rem; }
|
||||
.case-details summary { cursor: pointer; font-size: .82rem; }
|
||||
.case-description { white-space: pre-wrap; margin: .35rem 0 0; font-size: .88rem; }
|
||||
.report-filter label { display: inline-flex; flex-direction: column; font-size: .8rem; color: var(--text-2); gap: .2rem; }
|
||||
|
||||
/* Customer page: the instance the findings are filtered to. */
|
||||
tr.selected td { background: var(--accent-soft); }
|
||||
tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ public sealed record HubStatusRow
|
|||
public DateTime? LastErrorAt { get; init; }
|
||||
public DateTime UpdatedAt { 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
|
||||
|
|
|
|||
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>
|
||||
<Routes />
|
||||
<script src="@Assets["app.js"]"></script>
|
||||
<script src="@Assets["support-menu.js"]"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
@inherits LayoutComponentBase
|
||||
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
|
||||
@inject InsightRepository Repository
|
||||
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
|
||||
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
|
||||
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
|
||||
<NavLink href="/support"><Icon Name="support" /> Support</NavLink>
|
||||
|
||||
<span class="nav-label">Community</span>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
@if (_finding.ResolvedAt is { } resolved)
|
||||
{
|
||||
|
|
@ -21,6 +21,11 @@
|
|||
Set første gang @Display.Time(_finding.FirstSeenAt) · senest @Display.Time(_finding.LastSeenAt)
|
||||
@if (!string.IsNullOrEmpty(_finding.Subject)) { <span> · @_finding.Subject</span> }
|
||||
</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">
|
||||
<h2>Hvorfor er det et problem?</h2>
|
||||
|
|
@ -88,10 +93,12 @@
|
|||
private IReadOnlyList<SQLInsight.Contracts.UpgradeInsight> _elsewhere = [];
|
||||
private string? _version;
|
||||
private int _minCustomers;
|
||||
private bool _support;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_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)
|
||||
{
|
||||
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());
|
||||
|
||||
IntegrationEndpoints.Map(app);
|
||||
Support.Map(app);
|
||||
|
||||
// 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) =>
|
||||
|
|
|
|||
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:hover { filter: brightness(1.08); }
|
||||
.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));
|
||||
})();
|
||||
108
tests/SQLInsight.IntegrationTests/HubPortalSupportTests.cs
Normal file
108
tests/SQLInsight.IntegrationTests/HubPortalSupportTests.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using System.Net;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SQLInsight.Hub.Data;
|
||||
|
||||
namespace SQLInsight.IntegrationTests;
|
||||
|
||||
/// <summary>Support cases in the portal: suggested case numbers and the support report.</summary>
|
||||
public partial class HubPortalTests
|
||||
{
|
||||
private async Task<int> CustomerIdAsync(string customer)
|
||||
{
|
||||
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<HubDbContext>().Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_case_suggests_the_next_number_and_refuses_a_taken_one()
|
||||
{
|
||||
var customer = $"Forslag {Guid.NewGuid():N}"[..22];
|
||||
var finding = await SeedAsync(customer, hub.License(customer));
|
||||
var id = await CustomerIdAsync(customer);
|
||||
var client = await SignedInAsync();
|
||||
|
||||
Assert.Contains("value=\"SUP-000001\"", await client.GetStringAsync($"/portal/fund/{finding}"), StringComparison.Ordinal);
|
||||
var created = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Telefonopkald"), ("description", "Ringede om backup"));
|
||||
Assert.Equal($"/portal/kunder/{id}", created.Headers.Location?.OriginalString);
|
||||
var page = await client.GetStringAsync($"/portal/kunder/{id}");
|
||||
Assert.Contains("SUP-000001", page, StringComparison.Ordinal);
|
||||
Assert.Contains("value=\"SUP-000002\"", page, StringComparison.Ordinal);
|
||||
|
||||
var duplicate = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", "SUP-000001"), ("title", "Igen"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Support_report_shows_cases_and_exports_csv()
|
||||
{
|
||||
var customer = $"Rapport {Guid.NewGuid():N}"[..22];
|
||||
await SeedAsync(customer, hub.License(customer));
|
||||
var id = await CustomerIdAsync(customer);
|
||||
var client = await SignedInAsync();
|
||||
await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Til rapporten"));
|
||||
|
||||
var response = await client.GetAsync($"/portal/rapporter/support?kunde={id}");
|
||||
var page = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, page);
|
||||
var csv = await client.GetAsync($"/portal/actions/reports/support.csv?kunde={id}");
|
||||
var bytes = await csv.Content.ReadAsByteArrayAsync();
|
||||
|
||||
Assert.Contains("Til rapporten", page, StringComparison.Ordinal);
|
||||
Assert.Contains("median løsningstid", page, StringComparison.Ordinal);
|
||||
Assert.Equal("text/csv", csv.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]);
|
||||
Assert.Contains("SUP-000001;" + customer + ";Til rapporten;SEOSoft;", System.Text.Encoding.UTF8.GetString(bytes), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Customer_page_filters_findings_to_one_instance()
|
||||
{
|
||||
var customer = $"Instans {Guid.NewGuid():N}"[..22];
|
||||
var license = hub.License(customer);
|
||||
await SeedAsync(customer, license);
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
|
||||
{
|
||||
Content = System.Net.Http.Json.JsonContent.Create(HubApiTests.Submission("sql02", DateTimeOffset.UtcNow, ("SQLI-CFG-001", "", "High"))),
|
||||
})
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SQLInsight-License", license);
|
||||
(await hub.Factory.CreateClient().SendAsync(request)).EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var other = $"Fremmed {Guid.NewGuid():N}"[..22];
|
||||
await SeedAsync(other, hub.License(other));
|
||||
int id, sql02, foreign;
|
||||
await using (var scope = hub.Factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
|
||||
id = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
|
||||
sql02 = await db.Instances.Where(i => i.CustomerId == id && i.InstanceKey == "sql02").Select(i => i.Id).SingleAsync();
|
||||
foreign = await db.Instances.Where(i => i.Customer!.Name == other).Select(i => i.Id).SingleAsync();
|
||||
}
|
||||
|
||||
var client = await SignedInAsync();
|
||||
var all = await client.GetStringAsync($"/portal/kunder/{id}");
|
||||
var filtered = await client.GetStringAsync($"/portal/kunder/{id}?instans={sql02}");
|
||||
var ignored = await client.GetStringAsync($"/portal/kunder/{id}?instans={foreign}");
|
||||
|
||||
Assert.Contains("SQLI-DB-007", all, StringComparison.Ordinal);
|
||||
Assert.Contains("SQLI-CFG-001", all, StringComparison.Ordinal);
|
||||
Assert.Contains($"?instans={sql02}", all, StringComparison.Ordinal);
|
||||
Assert.Contains("Aktive fund · sql02", filtered, StringComparison.Ordinal);
|
||||
Assert.Contains("Vis alle instanser", filtered, StringComparison.Ordinal);
|
||||
Assert.Contains("SQLI-CFG-001", filtered, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("SQLI-DB-007", filtered, StringComparison.Ordinal);
|
||||
Assert.Contains("SQLI-DB-007", ignored, StringComparison.Ordinal);
|
||||
Assert.Contains("SQLI-CFG-001", ignored, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Vis alle instanser", ignored, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Support_report_csv_requires_login()
|
||||
{
|
||||
var response = await Client(hub.Factory).GetAsync("/portal/actions/reports/support.csv");
|
||||
|
||||
Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
142
tests/SQLInsight.IntegrationTests/HubSupportApiTests.cs
Normal file
142
tests/SQLInsight.IntegrationTests/HubSupportApiTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SQLInsight.Contracts;
|
||||
using SQLInsight.Hub.Data;
|
||||
using SQLInsight.Licensing;
|
||||
|
||||
namespace SQLInsight.IntegrationTests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public class HubSupportApiTests(HubFixture hub) : IClassFixture<HubFixture>
|
||||
{
|
||||
private static SupportCaseRequest Request(Guid? id = null, string title = "Backup fejler") =>
|
||||
new(id ?? Guid.NewGuid(), title, "Siden i går", "Anna Andersen", "anna@kunde.dk", "12345678", @"KUNDE\anna");
|
||||
|
||||
private async Task<HttpResponseMessage> PostAsync(string license, SupportCaseRequest request)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/cases") { Content = JsonContent.Create(request) };
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
|
||||
return await hub.Factory.CreateClient().SendAsync(message);
|
||||
}
|
||||
|
||||
private async Task<SupportCaseDto> CreateAsync(string license, SupportCaseRequest request)
|
||||
{
|
||||
var response = await PostAsync(license, request);
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||
return (await response.Content.ReadFromJsonAsync<SupportCaseDto>(JsonSerializerOptions.Web))!;
|
||||
}
|
||||
|
||||
private static string Name(string prefix) => $"{prefix} {Guid.NewGuid():N}"[..24];
|
||||
|
||||
[Fact]
|
||||
public async Task Customer_cases_are_numbered_SUP_000001_then_SUP_000002()
|
||||
{
|
||||
var customer = Name("Nummer");
|
||||
var license = hub.License(customer);
|
||||
|
||||
var first = await CreateAsync(license, Request());
|
||||
var second = await CreateAsync(license, Request());
|
||||
|
||||
Assert.Equal("SUP-000001", first.Reference);
|
||||
Assert.Equal("SUP-000002", second.Reference);
|
||||
Assert.Equal(SupportCaseSources.Customer, first.Source);
|
||||
Assert.True(first.IsOpen);
|
||||
|
||||
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||
var stored = await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.SingleAsync(c => c.Customer!.Name == customer && c.Reference == "SUP-000001");
|
||||
Assert.Equal(("Anna Andersen", "anna@kunde.dk", @"KUNDE\anna"), (stored.ContactName, stored.ContactEmail, stored.RequestedBy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_retried_request_returns_the_same_case()
|
||||
{
|
||||
var customer = Name("Igen");
|
||||
var license = hub.License(customer);
|
||||
var request = Request();
|
||||
|
||||
var first = await CreateAsync(license, request);
|
||||
var again = await CreateAsync(license, request);
|
||||
|
||||
Assert.Equal(first.Reference, again.Reference);
|
||||
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.CountAsync(c => c.Customer!.Name == customer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_customer_has_its_own_sequence()
|
||||
{
|
||||
await CreateAsync(hub.License(Name("Første")), Request());
|
||||
|
||||
var other = await CreateAsync(hub.License(Name("Anden")), Request());
|
||||
|
||||
Assert.Equal("SUP-000001", other.Reference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Community_license_cannot_create_cases()
|
||||
{
|
||||
var response = await PostAsync(hub.License(Name("Gratis"), LicensePlan.Community), Request());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_request_is_rejected()
|
||||
{
|
||||
var response = await PostAsync(hub.License(Name("Ugyldig")), Request() with { ContactEmail = "ingen-snabel-a" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Customer_sees_its_own_cases_including_those_support_created()
|
||||
{
|
||||
var customer = Name("Liste");
|
||||
var license = hub.License(customer);
|
||||
await CreateAsync(license, Request(title: "Fra kunden"));
|
||||
await CreateAsync(hub.License(Name("Fremmed")), Request(title: "Andens sag"));
|
||||
await using (var scope = hub.Factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
|
||||
var customerId = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
|
||||
await SQLInsight.Hub.Support.SupportCaseStore.AddAsync(db, new SupportCase
|
||||
{
|
||||
CustomerId = customerId, Reference = string.Empty, Title = "Fra SEOSoft", CreatedAt = DateTime.UtcNow, IsOpen = false, ClosedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, "/api/v1/cases");
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
|
||||
var cases = (await (await hub.Factory.CreateClient().SendAsync(message)).Content.ReadFromJsonAsync<List<SupportCaseDto>>(JsonSerializerOptions.Web))!;
|
||||
|
||||
Assert.Equal(["Fra SEOSoft", "Fra kunden"], cases.Select(c => c.Title));
|
||||
Assert.Equal("SUP-000002", cases[0].Reference);
|
||||
Assert.False(cases[0].IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Case_created_from_a_finding_is_linked_to_it()
|
||||
{
|
||||
var customer = Name("Fund");
|
||||
var license = hub.License(customer);
|
||||
using (var submit = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
|
||||
{
|
||||
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-DB-007", "database: Sales", "Critical"))),
|
||||
})
|
||||
{
|
||||
submit.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
|
||||
(await hub.Factory.CreateClient().SendAsync(submit)).EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var created = await CreateAsync(license, Request() with { InstanceName = "sql01", RuleId = "SQLI-DB-007", Subject = "database: Sales" });
|
||||
|
||||
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||
var finding = await scope.ServiceProvider.GetRequiredService<HubDbContext>().Findings.Include(f => f.SupportCase)
|
||||
.SingleAsync(f => f.Instance!.Customer!.Name == customer);
|
||||
Assert.Equal(created.Reference, finding.SupportCase?.Reference);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
99
tests/SQLInsight.IntegrationTests/HubSupportLogicTests.cs
Normal file
99
tests/SQLInsight.IntegrationTests/HubSupportLogicTests.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using SQLInsight.Contracts;
|
||||
using SQLInsight.Hub.Support;
|
||||
|
||||
namespace SQLInsight.IntegrationTests;
|
||||
|
||||
/// <summary>Pure logic behind support cases: no database, so these run with the unit tests.</summary>
|
||||
public class HubSupportLogicTests
|
||||
{
|
||||
[Fact]
|
||||
public void First_case_number_is_SUP_000001() => Assert.Equal("SUP-000001", CaseNumbers.Next([]));
|
||||
|
||||
[Fact]
|
||||
public void Next_number_follows_the_highest_SUP_number() =>
|
||||
Assert.Equal("SUP-000013", CaseNumbers.Next(["SUP-000002", "sup-000012", "SUP-000009"]));
|
||||
|
||||
[Fact]
|
||||
public void Hand_typed_references_do_not_affect_the_sequence() =>
|
||||
Assert.Equal("SUP-000004", CaseNumbers.Next(["SUP-000003", "INC-99999", "SUP-12A", "Sag fra telefon"]));
|
||||
|
||||
[Fact]
|
||||
public void Old_short_numbers_continue_in_six_digits() => Assert.Equal("SUP-001235", CaseNumbers.Next(["SUP-1234"]));
|
||||
|
||||
private static readonly DateTime T0 = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static SupportReportCase Case(int id, int customer, double createdDays, double? closedAfterHours, string source = SupportCaseSources.Staff) =>
|
||||
new(id, customer, $"Kunde {customer}", $"SUP-{id:D6}", "Titel", source, T0.AddDays(createdDays), closedAfterHours is { } h ? T0.AddDays(createdDays).AddHours(h) : null);
|
||||
|
||||
[Fact]
|
||||
public void Report_counts_created_closed_and_open_and_computes_average_and_median()
|
||||
{
|
||||
var cases = new[]
|
||||
{
|
||||
Case(1, 1, 1, 2, SupportCaseSources.Customer),
|
||||
Case(2, 1, 2, 4),
|
||||
Case(3, 1, 3, 30),
|
||||
Case(4, 1, 4, null, SupportCaseSources.Customer),
|
||||
Case(5, 2, -40, 1), // created and closed before the period: not counted
|
||||
Case(6, 2, -40, null), // created before, still open: open now
|
||||
};
|
||||
|
||||
var report = SupportReport.Build(cases, T0, T0.AddDays(30), T0.AddDays(10));
|
||||
var first = report.Customers.Single(c => c.CustomerId == 1);
|
||||
var second = report.Customers.Single(c => c.CustomerId == 2);
|
||||
|
||||
Assert.Equal((4, 3, 1), (first.Created, first.Closed, first.OpenNow));
|
||||
Assert.Equal(TimeSpan.FromHours(12), first.AverageResolution);
|
||||
Assert.Equal(TimeSpan.FromHours(4), first.MedianResolution);
|
||||
Assert.Equal(TimeSpan.FromDays(6), first.OldestOpenAge);
|
||||
Assert.Equal(0.5, first.CustomerCreatedShare);
|
||||
Assert.Equal((0, 0, 1), (second.Created, second.Closed, second.OpenNow));
|
||||
Assert.Equal(TimeSpan.FromDays(50), second.OldestOpenAge);
|
||||
Assert.Equal((4, 3, 2), (report.Total.Created, report.Total.Closed, report.Total.OpenNow));
|
||||
Assert.Equal([4, 3, 2, 1], report.Cases.Select(c => c.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Median_of_an_even_count_is_the_mean_of_the_middle_two() =>
|
||||
Assert.Equal(TimeSpan.FromHours(3), SupportReport.Median([TimeSpan.FromHours(1), TimeSpan.FromHours(2), TimeSpan.FromHours(4), TimeSpan.FromHours(9)]));
|
||||
|
||||
[Theory]
|
||||
[InlineData(30, "30 min")]
|
||||
[InlineData(90, "1,5 t")]
|
||||
[InlineData(4320, "3 dage")]
|
||||
public void Durations_are_readable(double minutes, string expected) => Assert.Equal(expected, SupportReport.Duration(TimeSpan.FromMinutes(minutes)));
|
||||
|
||||
[Fact]
|
||||
public void Default_period_is_the_last_90_days_including_today()
|
||||
{
|
||||
var (from, to) = SupportReportPeriod.Resolve(null, null, new DateTime(2026, 10, 1, 14, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc), to);
|
||||
Assert.Equal(to.AddDays(-90), from);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Csv_has_bom_semicolons_and_defuses_formulas()
|
||||
{
|
||||
var report = SupportReport.Build([Case(1, 1, 1, 6) with { Title = "=HYPERLINK(\"x\"); ond" }], T0, T0.AddDays(30), T0.AddDays(10));
|
||||
|
||||
var bytes = SupportReport.Csv(report);
|
||||
var text = System.Text.Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3);
|
||||
|
||||
Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]);
|
||||
Assert.StartsWith("Sag;Kunde;Titel;Kilde;", text, StringComparison.Ordinal);
|
||||
Assert.Contains("\"'=HYPERLINK(\"\"x\"\"); ond\"", text, StringComparison.Ordinal);
|
||||
Assert.Contains(";6,0\r\n", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Request_validation_rejects_missing_contact_and_long_titles()
|
||||
{
|
||||
var valid = new SupportCaseRequest(Guid.NewGuid(), "Langsom server", null, "Anna", "anna@kunde.dk", null, null);
|
||||
|
||||
Assert.Null(valid.Validate());
|
||||
Assert.NotNull((valid with { ContactEmail = "anna" }).Validate());
|
||||
Assert.NotNull((valid with { Title = new string('x', 201) }).Validate());
|
||||
Assert.NotNull((valid with { ClientRequestId = Guid.Empty }).Validate());
|
||||
}
|
||||
}
|
||||
|
|
@ -102,7 +102,11 @@ public class WebApiTests(SqlServerFixture sql) : IAsyncLifetime
|
|||
Assert.Contains("sqlinsight_rule_findings{instance=\"prod \\\"sql01\\\"\",rule=\"SQLI-BLK-001\",severity=\"high\"} 1", text, StringComparison.Ordinal);
|
||||
Assert.Contains("sqlinsight_metric{instance=\"prod \\\"sql01\\\"\",metric=\"cpu.sqlserver\"} 42.5", text, StringComparison.Ordinal);
|
||||
Assert.Contains("# TYPE sqlinsight_findings gauge", text, StringComparison.Ordinal);
|
||||
Assert.Contains("sqlinsight_build_info{version=\"1.0.0\"", text, StringComparison.Ordinal);
|
||||
// The version from Directory.Build.props (or the release tag), whatever it currently is.
|
||||
var version = typeof(IntegrationEndpoints).Assembly
|
||||
.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
|
||||
.Cast<System.Reflection.AssemblyInformationalVersionAttribute>().Single().InformationalVersion.Split('+')[0];
|
||||
Assert.Contains($"sqlinsight_build_info{{version=\"{version}\"", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
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