Compare commits

..

No commits in common. "4593ee8b0962b43428de6b5d9cd487a19e7a65ac" and "6d755aa3d02c799c28166c3e0618b36e273c9ae8" have entirely different histories.

50 changed files with 51 additions and 3068 deletions

View file

@ -9,9 +9,7 @@
# 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 (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)
# Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (offentlig licensnøgle fra keygen)
# =============================================================================
name: deploy hub
@ -61,13 +59,6 @@ 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"
@ -86,12 +77,6 @@ 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)"
{
@ -102,24 +87,8 @@ 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 "NOTE: SUPPORT_SMTP_* ikke sat – nye kundesager vises kun i portalen (ingen mail)" >&2
echo "WARNING: TRUSTED_KEY_ID/TRUSTED_KEY ikke sat – hubben kan ikke verificere licenser" >&2
fi
} > "$tmp/.env"
cp deploy/hub/docker-compose.prod.yml "$tmp/docker-compose.yml"

View file

@ -27,8 +27,6 @@ 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.

View file

@ -61,28 +61,6 @@ 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

View file

@ -1,25 +0,0 @@
# 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).

View file

@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.12",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View file

@ -108,89 +108,9 @@ 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);
@ -246,12 +166,6 @@ 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);
}

View file

@ -23,7 +23,6 @@ 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."),
];

View file

@ -1,83 +0,0 @@
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";
}

View file

@ -1,142 +0,0 @@
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();
}

View file

@ -1,42 +0,0 @@
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);
}
}

View file

@ -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 CustomerRegistration.LockAsync(db, license.Customer, cancellationToken).ConfigureAwait(false);
await db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({license.Customer}, 0))", cancellationToken).ConfigureAwait(false);
record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false);
if (record?.RevokedAt is not null)
{
@ -104,8 +104,25 @@ public static class FindingsEndpoint
return Results.Ok(new SubmissionReceipt(submission.SubmissionId, Duplicate: true, ActiveFindings: -1));
}
Customer customer;
(customer, record) = await CustomerRegistration.EnsureAsync(db, license, record, now, cancellationToken).ConfigureAwait(false);
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;
var instance = await db.Instances.SingleOrDefaultAsync(i => i.CustomerId == customer.Id && i.InstanceKey == submission.Instance.InstanceKey, cancellationToken).ConfigureAwait(false);
if (instance is null)
@ -216,5 +233,5 @@ public static class FindingsEndpoint
return seen.Count;
}
internal static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title);
private static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title);
}

View file

@ -96,7 +96,7 @@ public sealed class SupportCase
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
/// <summary>The case number, unique per customer. Suggested as SUP-000001, SUP-000002 … (see <see cref="Support.CaseNumbers"/>).</summary>
/// <summary>The case number in the support system, e.g. "SUP-1234".</summary>
public required string Reference { get; set; }
public required string Title { get; set; }
@ -105,20 +105,6 @@ 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>

View file

@ -59,16 +59,6 @@ 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 =>

View file

@ -1,586 +0,0 @@
// <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
}
}
}

View file

@ -1,141 +0,0 @@
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");
}
}
}

View file

@ -406,29 +406,10 @@ 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");
@ -437,11 +418,6 @@ 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");
@ -455,19 +431,6 @@ 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")
@ -476,17 +439,8 @@ namespace SQLInsight.Hub.Data.Migrations
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.HasIndex("CustomerId")
.HasDatabaseName("ix_support_cases_customer_id");
b.ToTable("support_cases", (string)null);
});

View file

@ -38,9 +38,6 @@ 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>

View file

@ -4,7 +4,6 @@
<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)
{
@ -18,12 +17,12 @@
@foreach (var c in _cases)
{
<tr>
<td class="mono nowrap">@c.Reference @if (c.Source == SQLInsight.Contracts.SupportCaseSources.Customer) { <span class="badge">Kunde</span> }</td>
<td class="mono">@c.Reference</td>
<td><a href="/portal/kunder/@c.CustomerId">@c.Customer!.Name</a></td>
<td>@c.Title<CaseDetails Case="c" /></td>
<td>@c.Title</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 HH:mm", CultureInfo.InvariantCulture) UTC</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
<td><form method="post" action="/portal/actions/cases/@c.Id/close"><AntiforgeryToken /><button type="submit" class="theme-toggle">Luk</button></form></td>
</tr>
}

View file

@ -17,30 +17,12 @@
<section class="card">
<div class="card-head"><h2>Instanser</h2></div>
<table>
<thead><tr><th>Instans</th><th>Aktive fund</th><th>Version</th><th>Agent</th><th>Seneste data</th></tr></thead>
<thead><tr><th>Instans</th><th>Version</th><th>Agent</th><th>Seneste data</th></tr></thead>
<tbody>
@foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey))
{
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>
<tr>
<td><strong>@i.InstanceKey</strong><div class="muted">@i.ServerName</div></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>
@ -51,14 +33,8 @@
</section>
<section class="card">
<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" />
<div class="card-head"><h2>Aktive fund</h2></div>
<FindingTable Rows="_findings" ShowCustomer="false" />
</section>
<section class="card">
@ -70,55 +46,25 @@
else
{
<table>
<thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th></tr></thead>
<tbody>
@foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
{
<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>
<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>
}
</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);
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);
_findings = _customer is null ? [] : await Queries.FindingsAsync(Id);
}
}

View file

@ -50,7 +50,7 @@
}
<form method="post" action="/portal/actions/findings/@_finding.Id/case" class="inline-form">
<AntiforgeryToken />
<input name="reference" value="@_nextReference" aria-label="Sagsnummer" title="Foreslået sagsnummer; kan rettes" />
<input name="reference" placeholder="Sagsnummer, fx SUP-1234" required />
<input name="title" placeholder="Titel (valgfri)" />
<button type="submit" class="primary">Opret sag</button>
</form>
@ -60,14 +60,9 @@
@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);
_nextReference = _finding is null ? string.Empty : await Queries.NextCaseReferenceAsync(_finding.Instance!.CustomerId);
}
protected override async Task OnInitializedAsync() => _finding = await Queries.FindingAsync(Id);
}

View file

@ -1,123 +0,0 @@
@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();
}
}

View file

@ -3,9 +3,7 @@ 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;
@ -27,8 +25,6 @@ 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);
@ -105,56 +101,21 @@ public static class PortalEndpoints
finding.SupportCaseId = supportCase.Id;
}
else
else if (!string.IsNullOrWhiteSpace(reference))
{
// An empty case number gets the customer's next SUP-number.
var (supportCase, error) = await SupportCaseStore.AddAsync(db, new SupportCase
var supportCase = new SupportCase
{
CustomerId = finding.Instance!.CustomerId,
Reference = reference ?? string.Empty,
Reference = reference.Trim(),
Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(),
CreatedAt = time.GetUtcNow().UtcDateTime,
}, ct);
if (supportCase is null)
{
return Results.BadRequest(error);
}
};
db.SupportCases.Add(supportCase);
await db.SaveChangesAsync(ct);
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");
}
}

View file

@ -63,10 +63,9 @@ 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, int? instanceId = null, CancellationToken ct = default) =>
public async Task<IReadOnlyList<FindingRow>> FindingsAsync(int? customerId = null, int minSeverity = 0, 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))
@ -80,43 +79,9 @@ 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)
.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);
}
.OrderBy(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false);
public async Task<IReadOnlyList<LicenseRecord>> LicensesAsync(CancellationToken ct = default) =>
await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false);

View file

@ -1,19 +0,0 @@
@* 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!;
}

View file

@ -5,14 +5,14 @@
else
{
<table>
<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>
<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>
<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> }
@if (ShowInstance) { <td>@f.Instance</td> }
<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,5 +26,4 @@ else
@code {
[Parameter, EditorRequired] public IReadOnlyList<FindingRow> Rows { get; set; } = [];
[Parameter] public bool ShowCustomer { get; set; } = true;
[Parameter] public bool ShowInstance { get; set; } = true;
}

View file

@ -9,7 +9,6 @@
<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">

View file

@ -66,7 +66,6 @@ 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));
@ -127,7 +126,6 @@ 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>();

View file

@ -14,6 +14,5 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="MailKit" />
</ItemGroup>
</Project>

View file

@ -1,35 +0,0 @@
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-&lt;digits&gt; 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();
}

View file

@ -1,74 +0,0 @@
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));
}

View file

@ -1,134 +0,0 @@
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);
}

View file

@ -1,153 +0,0 @@
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;
}
}

View file

@ -167,13 +167,3 @@ 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); }

View file

@ -19,13 +19,6 @@ 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

View file

@ -1,144 +0,0 @@
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);
}
}
}

View file

@ -1,36 +0,0 @@
/* 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;

View file

@ -14,6 +14,5 @@
<body>
<Routes />
<script src="@Assets["app.js"]"></script>
<script src="@Assets["support-menu.js"]"></script>
</body>
</html>

View file

@ -1,6 +1,5 @@
@inherits LayoutComponentBase
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
@inject InsightRepository Repository
<div class="shell">
<aside class="sidebar">
@ -15,7 +14,6 @@
<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>
@ -43,15 +41,3 @@
<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;
}

View file

@ -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 data-finding-id="@_finding.FindingId" tabindex="0"><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
<h1><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
<p class="lead">
@if (_finding.ResolvedAt is { } resolved)
{
@ -21,11 +21,6 @@
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>
@ -93,12 +88,10 @@
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;

View file

@ -1,101 +0,0 @@
@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);
}
}
}

View file

@ -100,7 +100,6 @@ 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) =>

View file

@ -1,88 +0,0 @@
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();
}

View file

@ -161,17 +161,3 @@ 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; }

View file

@ -1,78 +0,0 @@
// "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));
})();

View file

@ -1,108 +0,0 @@
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);
}
}

View file

@ -1,142 +0,0 @@
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);
}
}

View file

@ -1,81 +0,0 @@
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);
}
}

View file

@ -1,99 +0,0 @@
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());
}
}

View file

@ -102,11 +102,7 @@ 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);
// 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);
Assert.Contains("sqlinsight_build_info{version=\"1.0.0\"", text, StringComparison.Ordinal);
}
[Fact]

View file

@ -1,175 +0,0 @@
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);
}
}