Hub: support case numbers, customer case API, mail, support report

- Case numbers SUP-000001, SUP-000002 … per customer, unique per customer
  (new index; a migration step renames any old duplicates first). The
  portal suggests the next number on the finding page and under the new
  "Ny sag" on the customer page; an empty field gets the next number, a
  typed one that is taken is refused. Concurrent creates retry.
- SupportCase gains Source (Staff/Customer), description, contact name,
  e-mail, phone, the customer's Windows user and ClientRequestId.
- POST/GET /api/v1/cases for the customer's agent (license with priority
  support): creates a case idempotently on ClientRequestId, links the
  finding it came from, lists the customer's cases incl. staff-created.
  Customer/license creation is shared with the findings endpoint.
- Optional mail to support for customer cases (MailKit; prefers AUTH LOGIN
  as Microsoft 365 needs). A failed mail is logged, never fails the call.
  deploy-hub.yml writes optional SUPPORT_SMTP_* secrets to .env.
- Supportrapport: created, closed, open now, average/median resolution
  time, oldest open case and customer-created share per customer for a
  period (default 90 days), plus the cases; CSV for Danish Excel.
- Cases page: newest first, customer cases marked, contact/description.
- Customer page: findings can be filtered to one instance (?instans=id,
  ignored for another customer's instance); active findings per instance
  by severity.
- dotnet-ef as a local tool (dotnet-tools.json) for the migration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-01 11:33:55 +02:00
parent 6d755aa3d0
commit 83c247d536
32 changed files with 2201 additions and 49 deletions

View file

@ -9,7 +9,9 @@
# Alle miljøværdier (også IP'er) kommer fra Forgejo repo-secrets. # 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 # Påkrævede secrets: HOST_BIND HOST_PORT DB_SERVER DB_PORT DB_NAME DB_USER DB_PASSWORD KNOWN_PROXY
# Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (offentlig licensnøgle fra keygen) # Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (ekstra offentlig licensnøgle; k20261001 er indbygget)
# Valgfri: mail til support ved nye kundesager: SUPPORT_SMTP_HOST SUPPORT_SMTP_PORT (587) SUPPORT_SMTP_USER
# SUPPORT_SMTP_PASSWORD SUPPORT_SMTP_FROM SUPPORT_SMTP_TO (kommasepareret) SUPPORT_PORTAL_URL (link i mailen)
# ============================================================================= # =============================================================================
name: deploy hub name: deploy hub
@ -59,6 +61,13 @@ jobs:
KNOWN_PROXY: ${{ secrets.KNOWN_PROXY }} KNOWN_PROXY: ${{ secrets.KNOWN_PROXY }}
TRUSTED_KEY_ID: ${{ secrets.TRUSTED_KEY_ID }} TRUSTED_KEY_ID: ${{ secrets.TRUSTED_KEY_ID }}
TRUSTED_KEY: ${{ secrets.TRUSTED_KEY }} 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: | run: |
set -eu set -eu
required="HOST_BIND HOST_PORT DB_SERVER DB_PORT DB_NAME DB_USER DB_PASSWORD KNOWN_PROXY" required="HOST_BIND HOST_PORT DB_SERVER DB_PORT DB_NAME DB_USER DB_PASSWORD KNOWN_PROXY"
@ -77,6 +86,12 @@ jobs:
*"'"*|*";"*) echo "ERROR: secret $v indeholder ' eller ; – ikke understøttet"; exit 1 ;; *"'"*|*";"*) echo "ERROR: secret $v indeholder ' eller ; – ikke understøttet"; exit 1 ;;
esac esac
done 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)" tmp="$(mktemp -d)"
{ {
@ -87,8 +102,24 @@ jobs:
printf "SQLInsight__Hub__KnownProxies__0='%s'\n" "$KNOWN_PROXY" printf "SQLInsight__Hub__KnownProxies__0='%s'\n" "$KNOWN_PROXY"
if [ -n "${TRUSTED_KEY_ID:-}" ] && [ -n "${TRUSTED_KEY:-}" ]; then if [ -n "${TRUSTED_KEY_ID:-}" ] && [ -n "${TRUSTED_KEY:-}" ]; then
printf "SQLInsight__Hub__TrustedKeys__%s='%s'\n" "$TRUSTED_KEY_ID" "$TRUSTED_KEY" 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 else
echo "WARNING: TRUSTED_KEY_ID/TRUSTED_KEY ikke sat – hubben kan ikke verificere licenser" >&2 echo "NOTE: SUPPORT_SMTP_* ikke sat – nye kundesager vises kun i portalen (ingen mail)" >&2
fi fi
} > "$tmp/.env" } > "$tmp/.env"
cp deploy/hub/docker-compose.prod.yml "$tmp/docker-compose.yml" cp deploy/hub/docker-compose.prod.yml "$tmp/docker-compose.yml"

View file

@ -61,6 +61,28 @@ Hubben registrerer, når en instans skifter build, og beregner, hvilke fund der
Regler om selve versionen (SQLI-VER-*) er udeladt, fordi de altid forsvinder ved en opdatering. 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 ## Licenser
```bash ```bash

13
dotnet-tools.json Normal file
View file

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

View file

@ -0,0 +1,83 @@
namespace SQLInsight.Contracts;
/// <summary>
/// A support case a paying customer creates in SQLInsight Web, sent by the agent to POST /api/v1/cases.
/// <see cref="ClientRequestId"/> makes the request idempotent: a retry returns the case already created.
/// The finding fields are optional; when given, the hub links the customer's matching active finding to the case.
/// </summary>
public sealed record SupportCaseRequest(
Guid ClientRequestId,
string Title,
string? Description,
string ContactName,
string ContactEmail,
string? ContactPhone,
string? RequestedBy,
string? InstanceName = null,
string? RuleId = null,
string? Subject = null)
{
public const int MaxTitle = 200;
public const int MaxDescription = 8000;
public const int MaxContactName = 200;
public const int MaxContactEmail = 320;
public const int MaxContactPhone = 50;
public const int MaxRequestedBy = 256;
public const int MaxInstanceName = 128;
public const int MaxRuleId = 50;
public const int MaxSubject = 400;
/// <summary>Why the request cannot be accepted, or null when it is valid.</summary>
public string? Validate()
{
if (ClientRequestId == Guid.Empty)
{
return "ClientRequestId mangler.";
}
if (string.IsNullOrWhiteSpace(Title) || Title.Length > MaxTitle)
{
return $"Titel skal udfyldes (højst {MaxTitle} tegn).";
}
if (string.IsNullOrWhiteSpace(ContactName) || ContactName.Length > MaxContactName)
{
return $"Kontaktperson skal udfyldes (højst {MaxContactName} tegn).";
}
if (string.IsNullOrWhiteSpace(ContactEmail) || ContactEmail.Length > MaxContactEmail || !ContactEmail.Contains('@', StringComparison.Ordinal))
{
return "E-mail skal være en gyldig adresse.";
}
return (Description?.Length > MaxDescription, ContactPhone?.Length > MaxContactPhone, RequestedBy?.Length > MaxRequestedBy,
InstanceName?.Length > MaxInstanceName, RuleId?.Length > MaxRuleId, Subject?.Length > MaxSubject) switch
{
(true, _, _, _, _, _) => $"Beskrivelsen må højst være {MaxDescription} tegn.",
(_, true, _, _, _, _) => $"Telefonnummeret må højst være {MaxContactPhone} tegn.",
(_, _, true, _, _, _) => "Brugernavnet er for langt.",
(_, _, _, true, _, _) or (_, _, _, _, true, _) or (_, _, _, _, _, true) => "Fundets oplysninger er for lange.",
_ => null,
};
}
}
/// <summary>A support case as the hub reports it to the customer's agent (POST and GET /api/v1/cases).</summary>
public sealed record SupportCaseDto(
string Reference,
string Title,
bool IsOpen,
DateTimeOffset CreatedAt,
DateTimeOffset? ClosedAt,
string Source,
Guid? ClientRequestId);
/// <summary>Who created a support case.</summary>
public static class SupportCaseSources
{
/// <summary>SEOSoft staff, in the hub portal.</summary>
public const string Staff = "Staff";
/// <summary>The customer, in SQLInsight Web.</summary>
public const string Customer = "Customer";
}

View file

@ -0,0 +1,142 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SQLInsight.Contracts;
using SQLInsight.Hub.Data;
using SQLInsight.Hub.Support;
namespace SQLInsight.Hub.Api;
/// <summary>
/// Support cases for paying customers, through their agent:
/// POST /api/v1/cases creates a case (idempotent on ClientRequestId) and returns its number;
/// GET /api/v1/cases lists the customer's cases, also those SEOSoft created, so SQLInsight Web can show their status.
/// </summary>
public static class CasesEndpoint
{
private const int MaxListed = 200;
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous();
app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous();
}
internal static async Task<IResult> CreateAsync(
HttpRequest request, HubDbContext db, IOptions<HubServerOptions> options, SupportNotifier notifier, TimeProvider time, CancellationToken cancellationToken)
{
var (license, rejected) = await FindingsEndpoint.AuthorizeAsync(request, db, options.Value, time, cancellationToken).ConfigureAwait(false);
if (license is null)
{
return rejected!;
}
if (!license.IncludesPrioritySupport)
{
return FindingsEndpoint.Problem(403, "Licensen giver ikke support", "Supportsager kræver Professional eller Enterprise.");
}
if (request.ContentLength > 64 * 1024)
{
return FindingsEndpoint.Problem(413, "For stor", "Højst 64 KB.");
}
SupportCaseRequest? body;
try
{
body = await JsonSerializer.DeserializeAsync<SupportCaseRequest>(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
return FindingsEndpoint.Problem(400, "Ugyldig JSON", ex.Message);
}
if (body is null)
{
return FindingsEndpoint.Problem(400, "Ugyldig sag", "Body mangler.");
}
if (body.Validate() is { } invalid)
{
return FindingsEndpoint.Problem(422, "Ugyldig sag", invalid);
}
var now = time.GetUtcNow().UtcDateTime;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await CustomerRegistration.LockAsync(db, license.Customer, cancellationToken).ConfigureAwait(false);
var record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false);
if (record?.RevokedAt is not null)
{
return FindingsEndpoint.Problem(403, "Licensen er spærret", record.RevokedReason ?? "Kontakt SQLInsight-support.");
}
var (customer, _) = await CustomerRegistration.EnsureAsync(db, license, record, now, cancellationToken).ConfigureAwait(false);
var existing = await db.SupportCases.SingleOrDefaultAsync(c => c.CustomerId == customer.Id && c.ClientRequestId == body.ClientRequestId, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(SupportCaseStore.ToDto(existing));
}
var (created, error) = await SupportCaseStore.AddAsync(db, new SupportCase
{
CustomerId = customer.Id,
Reference = string.Empty,
Title = body.Title.Trim(),
Description = Trimmed(body.Description),
ContactName = body.ContactName.Trim(),
ContactEmail = body.ContactEmail.Trim(),
ContactPhone = Trimmed(body.ContactPhone),
RequestedBy = Trimmed(body.RequestedBy),
ClientRequestId = body.ClientRequestId,
Source = SupportCaseSources.Customer,
CreatedAt = now,
}, cancellationToken).ConfigureAwait(false);
if (created is null)
{
return FindingsEndpoint.Problem(409, "Sagen kunne ikke oprettes", error!);
}
// The finding the customer created the case from, if the hub has it as active and not yet in a case.
if (body.InstanceName is { Length: > 0 } instance && body.RuleId is { Length: > 0 } rule && body.Subject is { } subject)
{
var finding = await db.Findings.SingleOrDefaultAsync(f => f.Instance!.CustomerId == customer.Id && f.Instance.InstanceKey == instance
&& f.RuleId == rule && f.Subject == subject && f.ResolvedAt == null && f.SupportCaseId == null, cancellationToken).ConfigureAwait(false);
if (finding is not null)
{
finding.SupportCaseId = created.Id;
}
}
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
await notifier.NotifyAsync(created, customer.Name, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/cases/{Uri.EscapeDataString(created.Reference)}", SupportCaseStore.ToDto(created));
}
internal static async Task<IResult> ListAsync(
HttpRequest request, HubDbContext db, IOptions<HubServerOptions> options, TimeProvider time, CancellationToken cancellationToken)
{
var (license, rejected) = await FindingsEndpoint.AuthorizeAsync(request, db, options.Value, time, cancellationToken).ConfigureAwait(false);
if (license is null)
{
return rejected!;
}
if (!license.IncludesPrioritySupport)
{
return FindingsEndpoint.Problem(403, "Licensen giver ikke support", "Supportsager kræver Professional eller Enterprise.");
}
var cases = await db.SupportCases.AsNoTracking()
.Where(c => c.Customer!.Name == license.Customer)
.OrderByDescending(c => c.CreatedAt)
.Take(MaxListed)
.ToListAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList());
}
private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View file

@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using SQLInsight.Hub.Data;
using SQLInsight.Licensing;
namespace SQLInsight.Hub.Api;
/// <summary>
/// The customer and license record behind a verified license, created on first contact. Callers hold the
/// customer's advisory lock (<see cref="LockAsync"/>) in their transaction, so two first requests do not race.
/// </summary>
internal static class CustomerRegistration
{
/// <summary>Serializes everything for one customer until the transaction ends.</summary>
public static Task LockAsync(HubDbContext db, string customer, CancellationToken cancellationToken) =>
db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({customer}, 0))", cancellationToken);
/// <summary>Finds or creates the customer and the license record, and marks the license as seen now.</summary>
public static async Task<(Customer Customer, LicenseRecord Record)> EnsureAsync(
HubDbContext db, License license, LicenseRecord? record, DateTime now, CancellationToken cancellationToken)
{
var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == license.Customer, cancellationToken).ConfigureAwait(false);
if (customer is null)
{
customer = new Customer { Name = license.Customer, CreatedAt = now };
db.Customers.Add(customer);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
if (record is null)
{
record = new LicenseRecord
{
LicenseId = license.LicenseId, CustomerId = customer.Id, Plan = license.Plan.ToString(), IssuedAt = license.IssuedAt.UtcDateTime,
ExpiresAt = license.ExpiresAt.UtcDateTime, MaxInstances = license.MaxInstances, FirstSeenAt = now, LastSeenAt = now,
};
db.Licenses.Add(record);
}
record.LastSeenAt = now;
return (customer, record);
}
}

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. // instance (checked against the license's limit) must not race. Everything read before this point is re-read.
var now = time.GetUtcNow().UtcDateTime; var now = time.GetUtcNow().UtcDateTime;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({license.Customer}, 0))", cancellationToken).ConfigureAwait(false); await CustomerRegistration.LockAsync(db, license.Customer, cancellationToken).ConfigureAwait(false);
record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false); record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false);
if (record?.RevokedAt is not null) if (record?.RevokedAt is not null)
{ {
@ -104,25 +104,8 @@ public static class FindingsEndpoint
return Results.Ok(new SubmissionReceipt(submission.SubmissionId, Duplicate: true, ActiveFindings: -1)); return Results.Ok(new SubmissionReceipt(submission.SubmissionId, Duplicate: true, ActiveFindings: -1));
} }
var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == license.Customer, cancellationToken).ConfigureAwait(false); Customer customer;
if (customer is null) (customer, record) = await CustomerRegistration.EnsureAsync(db, license, record, now, cancellationToken).ConfigureAwait(false);
{
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); var instance = await db.Instances.SingleOrDefaultAsync(i => i.CustomerId == customer.Id && i.InstanceKey == submission.Instance.InstanceKey, cancellationToken).ConfigureAwait(false);
if (instance is null) if (instance is null)
@ -233,5 +216,5 @@ public static class FindingsEndpoint
return seen.Count; return seen.Count;
} }
private static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title); internal static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title);
} }

View file

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

View file

@ -59,6 +59,16 @@ public sealed class HubDbContext(DbContextOptions<HubDbContext> options) : DbCon
modelBuilder.Entity<SupportCase>(e => modelBuilder.Entity<SupportCase>(e =>
{ {
e.HasOne(c => c.Customer).WithMany(c => c.SupportCases).HasForeignKey(c => c.CustomerId); 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 => modelBuilder.Entity<InstanceVersionChange>(e =>

View file

@ -0,0 +1,586 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using SQLInsight.Hub.Data;
#nullable disable
namespace SQLInsight.Hub.Data.Migrations
{
[DbContext(typeof(HubDbContext))]
[Migration("20261001090936_SupportCases")]
partial class SupportCases
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_customers");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_customers_name");
b.ToTable("customers", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("evidence_json");
b.Property<string>("Explanation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("explanation");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("Recommendation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("recommendation");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("RuleId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rule_id");
b.Property<int>("Severity")
.HasColumnType("integer")
.HasColumnName("severity");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<int?>("SupportCaseId")
.HasColumnType("integer")
.HasColumnName("support_case_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_findings");
b.HasIndex("SupportCaseId")
.HasDatabaseName("ix_findings_support_case_id");
b.HasIndex("Severity", "ResolvedAt")
.HasDatabaseName("ix_findings_severity_resolved_at");
b.HasIndex("InstanceId", "RuleId", "Subject")
.IsUnique()
.HasDatabaseName("ix_findings_instance_id_rule_id_subject")
.HasFilter("resolved_at IS NULL");
b.ToTable("findings", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AgentVersion")
.HasColumnType("text")
.HasColumnName("agent_version");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<string>("Edition")
.HasColumnType("text")
.HasColumnName("edition");
b.Property<string>("InstanceKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("instance_key");
b.Property<DateTime?>("LastCollectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_collected_at");
b.Property<DateTime?>("LastSubmissionAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_submission_at");
b.Property<string>("ProductLevel")
.HasColumnType("text")
.HasColumnName("product_level");
b.Property<string>("ProductUpdateLevel")
.HasColumnType("text")
.HasColumnName("product_update_level");
b.Property<string>("ProductVersion")
.HasColumnType("text")
.HasColumnName("product_version");
b.Property<string>("Release")
.HasColumnType("text")
.HasColumnName("release");
b.Property<string>("ServerName")
.HasColumnType("text")
.HasColumnName("server_name");
b.HasKey("Id")
.HasName("pk_instances");
b.HasIndex("CustomerId", "InstanceKey")
.IsUnique()
.HasDatabaseName("ix_instances_customer_id_instance_key");
b.ToTable("instances", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.InstanceVersionChange", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("ChangedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("changed_at");
b.Property<string>("FromVersion")
.IsRequired()
.HasColumnType("text")
.HasColumnName("from_version");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<string>("ToLevel")
.HasColumnType("text")
.HasColumnName("to_level");
b.Property<string>("ToVersion")
.IsRequired()
.HasColumnType("text")
.HasColumnName("to_version");
b.HasKey("Id")
.HasName("pk_version_changes");
b.HasIndex("InstanceId", "ChangedAt")
.HasDatabaseName("ix_version_changes_instance_id_changed_at");
b.ToTable("version_changes", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
{
b.Property<string>("LicenseId")
.HasColumnType("text")
.HasColumnName("license_id");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("issued_at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<int>("MaxInstances")
.HasColumnType("integer")
.HasColumnName("max_instances");
b.Property<string>("Plan")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("RevokedReason")
.HasColumnType("text")
.HasColumnName("revoked_reason");
b.HasKey("LicenseId")
.HasName("pk_licenses");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_licenses_customer_id");
b.ToTable("licenses", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.RuleState", b =>
{
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<string>("RuleId")
.HasColumnType("text")
.HasColumnName("rule_id");
b.Property<string>("Outcome")
.IsRequired()
.HasColumnType("text")
.HasColumnName("outcome");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("InstanceId", "RuleId")
.HasName("pk_rule_states");
b.ToTable("rule_states", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.StaffUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password_hash");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("user_name");
b.HasKey("Id")
.HasName("pk_staff_users");
b.HasIndex("UserName")
.IsUnique()
.HasDatabaseName("ix_staff_users_user_name");
b.ToTable("staff_users", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.Submission", b =>
{
b.Property<Guid>("SubmissionId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("submission_id");
b.Property<bool>("Applied")
.HasColumnType("boolean")
.HasColumnName("applied");
b.Property<DateTime>("CollectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("collected_at");
b.Property<int>("FindingCount")
.HasColumnType("integer")
.HasColumnName("finding_count");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.HasKey("SubmissionId")
.HasName("pk_submissions");
b.HasIndex("ReceivedAt")
.HasDatabaseName("ix_submissions_received_at");
b.ToTable("submissions", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("ClientRequestId")
.HasColumnType("uuid")
.HasColumnName("client_request_id");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("closed_at");
b.Property<string>("ContactEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")
.HasColumnName("contact_email");
b.Property<string>("ContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("contact_name");
b.Property<string>("ContactPhone")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("contact_phone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<string>("Description")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)")
.HasColumnName("description");
b.Property<bool>("IsOpen")
.HasColumnType("boolean")
.HasColumnName("is_open");
b.Property<string>("Note")
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Reference")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("RequestedBy")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("requested_by");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasDefaultValue("Staff")
.HasColumnName("source");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_support_cases");
b.HasIndex("CreatedAt")
.HasDatabaseName("ix_support_cases_created_at");
b.HasIndex("CustomerId", "ClientRequestId")
.IsUnique()
.HasDatabaseName("ix_support_cases_customer_id_client_request_id")
.HasFilter("client_request_id IS NOT NULL");
b.HasIndex("CustomerId", "Reference")
.IsUnique()
.HasDatabaseName("ix_support_cases_customer_id_reference");
b.ToTable("support_cases", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
{
b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
.WithMany("Findings")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_findings_instances_instance_id");
b.HasOne("SQLInsight.Hub.Data.SupportCase", "SupportCase")
.WithMany("Findings")
.HasForeignKey("SupportCaseId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_findings_support_cases_support_case_id");
b.Navigation("Instance");
b.Navigation("SupportCase");
});
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
{
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
.WithMany("Instances")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_instances_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("SQLInsight.Hub.Data.InstanceVersionChange", b =>
{
b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
.WithMany()
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_version_changes_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
{
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
.WithMany("Licenses")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_licenses_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
{
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
.WithMany("SupportCases")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_support_cases_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
{
b.Navigation("Instances");
b.Navigation("Licenses");
b.Navigation("SupportCases");
});
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
{
b.Navigation("Findings");
});
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
{
b.Navigation("Findings");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SQLInsight.Hub.Data.Migrations
{
/// <inheritdoc />
public partial class SupportCases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_support_cases_customer_id",
table: "support_cases");
migrationBuilder.AddColumn<Guid>(
name: "client_request_id",
table: "support_cases",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "contact_email",
table: "support_cases",
type: "character varying(320)",
maxLength: 320,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "contact_name",
table: "support_cases",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "contact_phone",
table: "support_cases",
type: "character varying(50)",
maxLength: 50,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "description",
table: "support_cases",
type: "character varying(8000)",
maxLength: 8000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "requested_by",
table: "support_cases",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "source",
table: "support_cases",
type: "character varying(16)",
maxLength: 16,
nullable: false,
defaultValue: "Staff");
migrationBuilder.CreateIndex(
name: "ix_support_cases_created_at",
table: "support_cases",
column: "created_at");
migrationBuilder.CreateIndex(
name: "ix_support_cases_customer_id_client_request_id",
table: "support_cases",
columns: new[] { "customer_id", "client_request_id" },
unique: true,
filter: "client_request_id IS NOT NULL");
// Case numbers were free text before: give any duplicate within a customer a suffix so the unique index can be made.
migrationBuilder.Sql("""
UPDATE support_cases s SET reference = left(s.reference, 20) || '-' || s.id
WHERE EXISTS (SELECT 1 FROM support_cases d WHERE d.customer_id = s.customer_id AND d.reference = s.reference AND d.id < s.id);
""");
migrationBuilder.CreateIndex(
name: "ix_support_cases_customer_id_reference",
table: "support_cases",
columns: new[] { "customer_id", "reference" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_support_cases_created_at",
table: "support_cases");
migrationBuilder.DropIndex(
name: "ix_support_cases_customer_id_client_request_id",
table: "support_cases");
migrationBuilder.DropIndex(
name: "ix_support_cases_customer_id_reference",
table: "support_cases");
migrationBuilder.DropColumn(
name: "client_request_id",
table: "support_cases");
migrationBuilder.DropColumn(
name: "contact_email",
table: "support_cases");
migrationBuilder.DropColumn(
name: "contact_name",
table: "support_cases");
migrationBuilder.DropColumn(
name: "contact_phone",
table: "support_cases");
migrationBuilder.DropColumn(
name: "description",
table: "support_cases");
migrationBuilder.DropColumn(
name: "requested_by",
table: "support_cases");
migrationBuilder.DropColumn(
name: "source",
table: "support_cases");
migrationBuilder.CreateIndex(
name: "ix_support_cases_customer_id",
table: "support_cases",
column: "customer_id");
}
}
}

View file

@ -406,10 +406,29 @@ namespace SQLInsight.Hub.Data.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("ClientRequestId")
.HasColumnType("uuid")
.HasColumnName("client_request_id");
b.Property<DateTime?>("ClosedAt") b.Property<DateTime?>("ClosedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("closed_at"); .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") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@ -418,6 +437,11 @@ namespace SQLInsight.Hub.Data.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("customer_id"); .HasColumnName("customer_id");
b.Property<string>("Description")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)")
.HasColumnName("description");
b.Property<bool>("IsOpen") b.Property<bool>("IsOpen")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("is_open"); .HasColumnName("is_open");
@ -431,6 +455,19 @@ namespace SQLInsight.Hub.Data.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("reference"); .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") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@ -439,8 +476,17 @@ namespace SQLInsight.Hub.Data.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_support_cases"); .HasName("pk_support_cases");
b.HasIndex("CustomerId") b.HasIndex("CreatedAt")
.HasDatabaseName("ix_support_cases_customer_id"); .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); b.ToTable("support_cases", (string)null);
}); });

View file

@ -38,6 +38,9 @@ public sealed class HubServerOptions
/// <summary>Upgrade insights shared with agents (aggregated, anonymous).</summary> /// <summary>Upgrade insights shared with agents (aggregated, anonymous).</summary>
public Insights.InsightOptions Insights { get; set; } = new(); 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> /// <summary>How long the hub keeps data it no longer needs. Applied once a day.</summary>

View file

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

View file

@ -17,12 +17,30 @@
<section class="card"> <section class="card">
<div class="card-head"><h2>Instanser</h2></div> <div class="card-head"><h2>Instanser</h2></div>
<table> <table>
<thead><tr><th>Instans</th><th>Version</th><th>Agent</th><th>Seneste data</th></tr></thead> <thead><tr><th>Instans</th><th>Aktive fund</th><th>Version</th><th>Agent</th><th>Seneste data</th></tr></thead>
<tbody> <tbody>
@foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey)) @foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey))
{ {
<tr> var selected = i.Id == _instance?.Id;
<td><strong>@i.InstanceKey</strong><div class="muted">@i.ServerName</div></td> <tr class="@(selected ? "selected" : "")" aria-current="@(selected ? "true" : null)">
<td><a href="/portal/kunder/@_customer.Id?instans=@i.Id" title="Vis kun fund på @i.InstanceKey"><strong>@i.InstanceKey</strong></a><div class="muted">@i.ServerName</div></td>
<td class="nowrap">
@if (_counts.TryGetValue(i.Id, out var counts) && counts.Count > 0)
{
@foreach (var (severity, count) in counts.Where(c => c.Key >= 2).OrderByDescending(c => c.Key))
{
<Sev Severity="severity" Count="count" /> @(" ")
}
@if (counts.Where(c => c.Key < 2).Sum(c => c.Value) is var minor and > 0)
{
<span class="muted">+@minor lav/info</span>
}
}
else
{
<span class="muted">Ingen</span>
}
</td>
<td>@i.Release<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel · @i.Edition</div></td> <td>@i.Release<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel · @i.Edition</div></td>
<td>@i.AgentVersion</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> <td class="nowrap @(i.LastSubmissionAt < Queries.Now.AddHours(-1) ? "bad" : "")">@(i.LastSubmissionAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) UTC</td>
@ -33,8 +51,14 @@
</section> </section>
<section class="card"> <section class="card">
<div class="card-head"><h2>Aktive fund</h2></div> <div class="card-head">
<FindingTable Rows="_findings" ShowCustomer="false" /> <h2>Aktive fund@(_instance is null ? "" : $" · {_instance.InstanceKey}")</h2>
@if (_instance is not null)
{
<a href="/portal/kunder/@_customer.Id">Vis alle instanser</a>
}
</div>
<FindingTable Rows="_findings" ShowCustomer="false" ShowInstance="_instance is null" />
</section> </section>
<section class="card"> <section class="card">
@ -46,25 +70,55 @@
else else
{ {
<table> <table>
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th></tr></thead> <thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
<tbody> <tbody>
@foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt)) @foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
{ {
<tr><td class="mono">@c.Reference</td><td>@c.Title</td><td>@(c.IsOpen ? "Åben" : "Lukket")</td><td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td></tr> <tr>
<td class="mono">@c.Reference</td>
<td>@c.Title<CaseDetails Case="c" /></td>
<td>@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")</td>
<td>@(c.IsOpen ? "Åben" : "Lukket")</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
</tr>
} }
</tbody> </tbody>
</table> </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> </section>
@code { @code {
[Parameter] public int Id { get; set; } [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 Customer? _customer;
private Instance? _instance;
private IReadOnlyList<FindingRow> _findings = []; 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() protected override async Task OnInitializedAsync()
{ {
_customer = await Queries.CustomerAsync(Id); _customer = await Queries.CustomerAsync(Id);
_findings = _customer is null ? [] : await Queries.FindingsAsync(Id); if (_customer is null)
{
return;
}
_instance = _customer.Instances.FirstOrDefault(i => i.Id == Instans);
_findings = await Queries.FindingsAsync(Id, instanceId: _instance?.Id);
_counts = await Queries.ActiveFindingCountsAsync(Id);
_nextReference = await Queries.NextCaseReferenceAsync(Id);
} }
} }

View file

@ -50,7 +50,7 @@
} }
<form method="post" action="/portal/actions/findings/@_finding.Id/case" class="inline-form"> <form method="post" action="/portal/actions/findings/@_finding.Id/case" class="inline-form">
<AntiforgeryToken /> <AntiforgeryToken />
<input name="reference" placeholder="Sagsnummer, fx SUP-1234" required /> <input name="reference" value="@_nextReference" aria-label="Sagsnummer" title="Foreslået sagsnummer; kan rettes" />
<input name="title" placeholder="Titel (valgfri)" /> <input name="title" placeholder="Titel (valgfri)" />
<button type="submit" class="primary">Opret sag</button> <button type="submit" class="primary">Opret sag</button>
</form> </form>
@ -60,9 +60,14 @@
@code { @code {
[Parameter] public long Id { get; set; } [Parameter] public long Id { get; set; }
private HubFinding? _finding; private HubFinding? _finding;
private string _nextReference = string.Empty;
private IReadOnlyList<SQLInsight.Contracts.EvidenceDto> Evidence => private IReadOnlyList<SQLInsight.Contracts.EvidenceDto> Evidence =>
System.Text.Json.JsonSerializer.Deserialize<List<SQLInsight.Contracts.EvidenceDto>>(_finding!.EvidenceJson, System.Text.Json.JsonSerializerOptions.Web) ?? []; System.Text.Json.JsonSerializer.Deserialize<List<SQLInsight.Contracts.EvidenceDto>>(_finding!.EvidenceJson, System.Text.Json.JsonSerializerOptions.Web) ?? [];
protected override async Task OnInitializedAsync() => _finding = await Queries.FindingAsync(Id); protected override async Task OnInitializedAsync()
{
_finding = await Queries.FindingAsync(Id);
_nextReference = _finding is null ? string.Empty : await Queries.NextCaseReferenceAsync(_finding.Instance!.CustomerId);
}
} }

View file

@ -0,0 +1,123 @@
@page "/portal/rapporter/support"
@attribute [Authorize]
@using SQLInsight.Hub.Support
@inject PortalQueries Queries
<PageTitle>Supportrapport · SQLInsight Hub</PageTitle>
@if (_report is null)
{
return;
}
<h1>Supportrapport</h1>
<p class="lead">
Sager oprettet og lukket i perioden, åbne sager nu og hvor hurtigt sagerne blev løst.
Løsningstid er tiden fra oprettelse til lukning for sager lukket i perioden. Tider er UTC.
</p>
<form method="get" action="/portal/rapporter/support" class="inline-form report-filter">
<label>Fra <input type="date" name="fra" value="@_report.From.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)" /></label>
<label>Til og med <input type="date" name="til" value="@_report.To.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)" /></label>
<label>Kunde
<select name="kunde">
<option value="">Alle kunder</option>
@foreach (var (id, name) in _customers)
{
<option value="@id" selected="@(id == Kunde)">@name</option>
}
</select>
</label>
<button type="submit" class="primary">Vis</button>
<a class="theme-toggle" href="@CsvUrl">Hent CSV</a>
</form>
<section class="kpis">
<div class="kpi"><div><strong>@_report.Total.Created</strong><span>oprettet</span></div></div>
<div class="kpi"><div><strong>@_report.Total.Closed</strong><span>lukket</span></div></div>
<div class="kpi"><div><strong>@_report.Total.OpenNow</strong><span>åbne nu</span></div></div>
<div class="kpi"><div><strong>@SupportReport.Duration(_report.Total.MedianResolution)</strong><span>median løsningstid</span></div></div>
<div class="kpi"><div><strong>@SupportReport.Duration(_report.Total.AverageResolution)</strong><span>gennemsnitlig løsningstid</span></div></div>
</section>
<section class="card">
<div class="card-head"><h2>Pr. kunde</h2></div>
@if (_report.Customers.Count == 0)
{
<p class="empty">Ingen sager i perioden.</p>
}
else
{
<table>
<thead>
<tr>
<th>Kunde</th><th class="num">Oprettet</th><th class="num">Lukket</th><th class="num">Åbne nu</th>
<th>Gennemsnit</th><th>Median</th><th>Ældste åbne</th><th class="num">Oprettet af kunden</th>
</tr>
</thead>
<tbody>
@foreach (var c in _report.Customers.Append(_report.Total))
{
<tr class="@(c.CustomerId is null ? "total" : "")">
<td>@if (c.CustomerId is { } id) { <a href="/portal/kunder/@id">@c.Customer</a> } else { <strong>@c.Customer</strong> }</td>
<td class="num">@c.Created</td>
<td class="num">@c.Closed</td>
<td class="num">@c.OpenNow</td>
<td class="nowrap">@SupportReport.Duration(c.AverageResolution)</td>
<td class="nowrap">@SupportReport.Duration(c.MedianResolution)</td>
<td class="nowrap">@SupportReport.Duration(c.OldestOpenAge)</td>
<td class="num">@(c.CustomerCreatedShare is { } share ? share.ToString("P0", Danish) : "–")</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<div class="card-head"><h2>Sager i perioden</h2></div>
@if (_report.Cases.Count == 0)
{
<p class="empty">Ingen.</p>
}
else
{
<table>
<thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th>Kilde</th><th>Oprettet</th><th>Lukket</th><th>Løsningstid</th></tr></thead>
<tbody>
@foreach (var c in _report.Cases)
{
<tr>
<td class="mono nowrap">@c.Reference</td>
<td><a href="/portal/kunder/@c.CustomerId">@c.Customer</a></td>
<td>@c.Title</td>
<td>@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))</td>
</tr>
}
</tbody>
</table>
}
</section>
@code {
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
[SupplyParameterFromQuery] public DateTime? Fra { get; set; }
[SupplyParameterFromQuery] public DateTime? Til { get; set; }
[SupplyParameterFromQuery] public int? Kunde { get; set; }
private SupportReportResult? _report;
private IReadOnlyList<(int Id, string Name)> _customers = [];
private string CsvUrl =>
$"/portal/actions/reports/support.csv?fra={_report!.From:yyyy-MM-dd}&til={_report.To.AddDays(-1):yyyy-MM-dd}{(Kunde is { } k ? $"&kunde={k}" : "")}";
protected override async Task OnInitializedAsync()
{
var (from, to) = SupportReportPeriod.Resolve(Fra, Til, Queries.Now);
_report = await Queries.SupportReportAsync(from, to, Kunde);
_customers = await Queries.CustomerNamesAsync();
}
}

View file

@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SQLInsight.Contracts;
using SQLInsight.Hub.Data; using SQLInsight.Hub.Data;
using SQLInsight.Hub.Support;
namespace SQLInsight.Hub.Portal; namespace SQLInsight.Hub.Portal;
@ -25,6 +27,8 @@ public static class PortalEndpoints
var portal = app.MapGroup("/portal/actions").RequireAuthorization(); var portal = app.MapGroup("/portal/actions").RequireAuthorization();
portal.MapPost("/findings/{id:long}/case", LinkCaseAsync); 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) => portal.MapPost("/findings/{id:long}/unlink", async (long id, HubDbContext db, CancellationToken ct) =>
{ {
var finding = await db.Findings.FindAsync([id], ct); var finding = await db.Findings.FindAsync([id], ct);
@ -101,21 +105,56 @@ public static class PortalEndpoints
finding.SupportCaseId = supportCase.Id; finding.SupportCaseId = supportCase.Id;
} }
else if (!string.IsNullOrWhiteSpace(reference)) else
{ {
var supportCase = new SupportCase // An empty case number gets the customer's next SUP-number.
var (supportCase, error) = await SupportCaseStore.AddAsync(db, new SupportCase
{ {
CustomerId = finding.Instance!.CustomerId, CustomerId = finding.Instance!.CustomerId,
Reference = reference.Trim(), Reference = reference ?? string.Empty,
Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(), Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(),
CreatedAt = time.GetUtcNow().UtcDateTime, CreatedAt = time.GetUtcNow().UtcDateTime,
}; }, ct);
db.SupportCases.Add(supportCase); if (supportCase is null)
await db.SaveChangesAsync(ct); {
return Results.BadRequest(error);
}
finding.SupportCaseId = supportCase.Id; finding.SupportCaseId = supportCase.Id;
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return Results.Redirect($"/portal/fund/{id}"); 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,9 +63,10 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time)
return db.Instances.CountAsync(i => i.LastSubmissionAt < limit, ct); return db.Instances.CountAsync(i => i.LastSubmissionAt < limit, ct);
} }
public async Task<IReadOnlyList<FindingRow>> FindingsAsync(int? customerId = null, int minSeverity = 0, CancellationToken ct = default) => public async Task<IReadOnlyList<FindingRow>> FindingsAsync(int? customerId = null, int minSeverity = 0, int? instanceId = null, CancellationToken ct = default) =>
await db.Findings await db.Findings
.Where(f => f.ResolvedAt == null && f.Severity >= minSeverity && (customerId == null || f.Instance!.CustomerId == customerId)) .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) .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, .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)) f.SupportCase == null ? null : f.SupportCase.Reference))
@ -79,9 +80,43 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time)
db.Findings.Include(f => f.Instance!).ThenInclude(i => i.Customer!).ThenInclude(c => c.SupportCases).Include(f => f.SupportCase) 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); .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) => public async Task<IReadOnlyList<SupportCase>> OpenCasesAsync(CancellationToken ct = default) =>
await db.SupportCases.Include(c => c.Customer).Include(c => c.Findings).Where(c => c.IsOpen) await db.SupportCases.Include(c => c.Customer).Include(c => c.Findings).Where(c => c.IsOpen)
.OrderBy(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false); .OrderByDescending(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false);
/// <summary>Active findings per instance of a customer and severity: instance id → severity → count.</summary>
public async Task<IReadOnlyDictionary<int, IReadOnlyDictionary<int, int>>> ActiveFindingCountsAsync(int customerId, CancellationToken ct = default)
{
var rows = await db.Findings
.Where(f => f.ResolvedAt == null && f.Instance!.CustomerId == customerId)
.GroupBy(f => new { f.InstanceId, f.Severity })
.Select(g => new { g.Key.InstanceId, g.Key.Severity, Count = g.Count() })
.ToListAsync(ct).ConfigureAwait(false);
return rows.GroupBy(r => r.InstanceId)
.ToDictionary(g => g.Key, g => (IReadOnlyDictionary<int, int>)g.ToDictionary(r => r.Severity, r => r.Count));
}
public Task<string> NextCaseReferenceAsync(int customerId, CancellationToken ct = default) =>
Support.SupportCaseStore.NextReferenceAsync(db, customerId, ct);
public async Task<IReadOnlyList<(int Id, string Name)>> CustomerNamesAsync(CancellationToken ct = default) =>
(await db.Customers.OrderBy(c => c.Name).Select(c => new { c.Id, c.Name }).ToListAsync(ct).ConfigureAwait(false))
.Select(c => (c.Id, c.Name)).ToList();
/// <summary>The support report for [from, to) UTC, for all customers or one.</summary>
public async Task<Support.SupportReportResult> SupportReportAsync(DateTime from, DateTime to, int? customerId, CancellationToken ct = default)
{
var cases = await db.SupportCases
.Where(c => customerId == null || c.CustomerId == customerId)
.Where(c => c.IsOpen || (c.CreatedAt >= from && c.CreatedAt < to) || (c.ClosedAt >= from && c.ClosedAt < to))
.Select(c => new { c.Id, c.CustomerId, Customer = c.Customer!.Name, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen, c.ClosedAt })
.ToListAsync(ct).ConfigureAwait(false);
// A closed case without a closing time (closed before it was recorded) counts as closed when it was created.
return Support.SupportReport.Build(
cases.Select(c => new Support.SupportReportCase(c.Id, c.CustomerId, c.Customer, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen ? null : c.ClosedAt ?? c.CreatedAt)),
from, to, Now);
}
public async Task<IReadOnlyList<LicenseRecord>> LicensesAsync(CancellationToken ct = default) => public async Task<IReadOnlyList<LicenseRecord>> LicensesAsync(CancellationToken ct = default) =>
await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false); await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false);

View file

@ -0,0 +1,19 @@
@* Contact and description of a case, folded away: shown under the title in case lists. *@
@if (Case.ContactName is not null || Case.Description is not null)
{
<details class="case-details">
<summary class="muted">@(Case.ContactName is { } name ? $"{name}{(Case.RequestedBy is { } user ? $" ({user})" : "")}" : "Beskrivelse")</summary>
@if (Case.ContactEmail is { } email)
{
<div><a href="mailto:@email">@email</a>@(Case.ContactPhone is { } phone ? $" · {phone}" : "")</div>
}
@if (Case.Description is { } description)
{
<p class="case-description">@description</p>
}
</details>
}
@code {
[Parameter, EditorRequired] public SupportCase Case { get; set; } = null!;
}

View file

@ -5,14 +5,14 @@
else else
{ {
<table> <table>
<thead><tr><th>Alvor</th>@if (ShowCustomer) { <th>Kunde</th> }<th>Instans</th><th>Fund</th><th>Regel</th><th>Sag</th><th>Siden</th></tr></thead> <thead><tr><th>Alvor</th>@if (ShowCustomer) { <th>Kunde</th> }@if (ShowInstance) { <th>Instans</th> }<th>Fund</th><th>Regel</th><th>Sag</th><th>Siden</th></tr></thead>
<tbody> <tbody>
@foreach (var f in Rows) @foreach (var f in Rows)
{ {
<tr> <tr>
<td><Sev Severity="f.Severity" /></td> <td><Sev Severity="f.Severity" /></td>
@if (ShowCustomer) { <td><a href="/portal/kunder/@f.CustomerId">@f.Customer</a></td> } @if (ShowCustomer) { <td><a href="/portal/kunder/@f.CustomerId">@f.Customer</a></td> }
<td>@f.Instance</td> @if (ShowInstance) { <td>@f.Instance</td> }
<td><a href="/portal/fund/@f.Id">@f.Title</a></td> <td><a href="/portal/fund/@f.Id">@f.Title</a></td>
<td class="mono">@f.RuleId</td> <td class="mono">@f.RuleId</td>
<td>@(f.CaseReference ?? "–")</td> <td>@(f.CaseReference ?? "–")</td>
@ -26,4 +26,5 @@ else
@code { @code {
[Parameter, EditorRequired] public IReadOnlyList<FindingRow> Rows { get; set; } = []; [Parameter, EditorRequired] public IReadOnlyList<FindingRow> Rows { get; set; } = [];
[Parameter] public bool ShowCustomer { get; set; } = true; [Parameter] public bool ShowCustomer { get; set; } = true;
[Parameter] public bool ShowInstance { get; set; } = true;
} }

View file

@ -9,6 +9,7 @@
<NavLink href="/portal/fund">Alle fund</NavLink> <NavLink href="/portal/fund">Alle fund</NavLink>
<NavLink href="/portal/indsigt">Opgraderinger</NavLink> <NavLink href="/portal/indsigt">Opgraderinger</NavLink>
<NavLink href="/portal/sager">Supportsager</NavLink> <NavLink href="/portal/sager">Supportsager</NavLink>
<NavLink href="/portal/rapporter/support">Supportrapport</NavLink>
<NavLink href="/portal/licenser">Licenser</NavLink> <NavLink href="/portal/licenser">Licenser</NavLink>
</nav> </nav>
<div class="support-card"> <div class="support-card">

View file

@ -66,6 +66,7 @@ public static class HubProgram
builder.Services.AddHostedService<HubRetentionService>(); builder.Services.AddHostedService<HubRetentionService>();
builder.Services.AddScoped<StaffAccounts>(); builder.Services.AddScoped<StaffAccounts>();
builder.Services.AddScoped<PortalQueries>(); builder.Services.AddScoped<PortalQueries>();
builder.Services.AddSingleton<SQLInsight.Hub.Support.SupportNotifier>();
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All)); builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All));
@ -126,6 +127,7 @@ public static class HubProgram
app.MapGet("/", () => Results.Redirect("/portal")); app.MapGet("/", () => Results.Redirect("/portal"));
app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
FindingsEndpoint.Map(app); FindingsEndpoint.Map(app);
CasesEndpoint.Map(app);
SQLInsight.Hub.Insights.InsightsEndpoint.Map(app); SQLInsight.Hub.Insights.InsightsEndpoint.Map(app);
PortalEndpoints.Map(app); PortalEndpoints.Map(app);
app.MapRazorComponents<PortalApp>(); app.MapRazorComponents<PortalApp>();

View file

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

View file

@ -0,0 +1,35 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace SQLInsight.Hub.Support;
/// <summary>Case numbers SUP-000001, SUP-000002 … running per customer.</summary>
public static partial class CaseNumbers
{
public const string Prefix = "SUP-";
/// <summary>
/// The number after the highest SUP-&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

@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using SQLInsight.Contracts;
using SQLInsight.Hub.Data;
namespace SQLInsight.Hub.Support;
/// <summary>Creates support cases with a case number that is unique per customer.</summary>
public static class SupportCaseStore
{
private const int Attempts = 5;
/// <summary>The number the next case for the customer gets unless another is typed: SUP-000001, SUP-000002 …</summary>
public static async Task<string> NextReferenceAsync(HubDbContext db, int customerId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(db);
var references = await db.SupportCases.Where(c => c.CustomerId == customerId).Select(c => c.Reference).ToListAsync(cancellationToken).ConfigureAwait(false);
return CaseNumbers.Next(references);
}
/// <summary>
/// Adds <paramref name="draft"/> and saves it. An empty <see cref="SupportCase.Reference"/> gets the next free number;
/// if someone else took it meanwhile, the next one is tried. A typed reference that already exists for the customer
/// is refused. Inside a transaction EF Core rolls back to a savepoint on failure, so retrying is safe there too.
/// </summary>
/// <returns>The saved case, or the reason it was not saved.</returns>
public static async Task<(SupportCase? Case, string? Error)> AddAsync(HubDbContext db, SupportCase draft, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(db);
ArgumentNullException.ThrowIfNull(draft);
draft.Reference = draft.Reference?.Trim() ?? string.Empty;
var typed = draft.Reference.Length > 0;
if (typed && draft.Reference.Length > 32)
{
return (null, "Sagsnummeret må højst være 32 tegn.");
}
for (var attempt = 0; attempt < Attempts; attempt++)
{
if (!typed)
{
draft.Reference = await NextReferenceAsync(db, draft.CustomerId, cancellationToken).ConfigureAwait(false);
}
else if (await db.SupportCases.AnyAsync(c => c.CustomerId == draft.CustomerId && c.Reference == draft.Reference, cancellationToken).ConfigureAwait(false))
{
return (null, $"Sagsnummeret {draft.Reference} findes allerede for kunden.");
}
db.SupportCases.Add(draft);
try
{
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return (draft, null);
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation })
{
db.Entry(draft).State = EntityState.Detached;
if (typed)
{
return (null, $"Sagsnummeret {draft.Reference} findes allerede for kunden.");
}
}
}
return (null, "Der kunne ikke tildeles et sagsnummer. Prøv igen.");
}
public static SupportCaseDto ToDto(SupportCase c)
{
ArgumentNullException.ThrowIfNull(c);
return new SupportCaseDto(c.Reference, c.Title, c.IsOpen, Utc(c.CreatedAt), c.ClosedAt is { } closed ? Utc(closed) : null, c.Source, c.ClientRequestId);
}
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
}

View file

@ -0,0 +1,134 @@
using System.Net;
using System.Text;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using SQLInsight.Hub.Data;
namespace SQLInsight.Hub.Support;
/// <summary>SQLInsight:Hub:Support: what happens when a customer creates a support case.</summary>
public sealed class SupportOptions
{
public SupportNotifyOptions Notify { get; set; } = new();
}
/// <summary>E-mail to SEOSoft's support when a customer creates a case in SQLInsight Web.</summary>
public sealed class SupportNotifyOptions
{
public bool Enabled { get; set; }
public string? SmtpHost { get; set; }
public int SmtpPort { get; set; } = 587;
/// <summary>STARTTLS on the port (Microsoft 365: smtp.office365.com:587). False: TLS from the start (port 465).</summary>
public bool StartTls { get; set; } = true;
public string? UserName { get; set; }
public string? Password { get; set; }
public string? From { get; set; }
public IList<string> To { get; } = [];
/// <summary>The portal's public address, for a link to the case in the mail (e.g. https://hub.sqlinsight.seosoft.dk/).</summary>
public Uri? PortalUrl { get; set; }
public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(SmtpHost) && !string.IsNullOrWhiteSpace(From) && To.Count > 0;
}
/// <summary>
/// Mails support about a new customer case. A mail that cannot be sent is logged and otherwise ignored: the case is
/// saved and visible in the portal either way.
/// </summary>
public sealed partial class SupportNotifier(IOptions<HubServerOptions> options, ILogger<SupportNotifier> logger)
{
public async Task NotifyAsync(SupportCase supportCase, string customer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(supportCase);
var settings = options.Value.Support.Notify;
if (!settings.IsConfigured)
{
return;
}
try
{
using var client = new SmtpClient { Timeout = 20_000 };
await client.ConnectAsync(settings.SmtpHost!, settings.SmtpPort, settings.StartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.SslOnConnect, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(settings.UserName))
{
// A password can never do OAuth, and Microsoft 365 rejects PLAIN: prefer LOGIN where it is offered.
client.AuthenticationMechanisms.Remove("XOAUTH2");
if (client.AuthenticationMechanisms.Contains("LOGIN"))
{
client.AuthenticationMechanisms.Remove("PLAIN");
}
await client.AuthenticateAsync(settings.UserName, settings.Password ?? string.Empty, cancellationToken).ConfigureAwait(false);
}
await client.SendAsync(Build(settings, supportCase, customer), cancellationToken).ConfigureAwait(false);
await client.DisconnectAsync(true, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogMailFailed(logger, ex, supportCase.Reference, customer);
}
}
internal static MimeMessage Build(SupportNotifyOptions settings, SupportCase c, string customer)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(settings.From!));
foreach (var to in settings.To)
{
message.To.Add(MailboxAddress.Parse(to));
}
if (c.ContactEmail is { Length: > 0 } email && MailboxAddress.TryParse(email, out var replyTo))
{
replyTo.Name = c.ContactName;
message.ReplyTo.Add(replyTo);
}
message.Subject = $"Ny supportsag {c.Reference} fra {customer}: {c.Title}";
var lines = new List<(string Label, string? Value)>
{
("Kunde", customer),
("Sag", c.Reference),
("Titel", c.Title),
("Kontakt", c.ContactName),
("E-mail", c.ContactEmail),
("Telefon", c.ContactPhone),
("Oprettet af", c.RequestedBy),
};
var text = new StringBuilder();
var html = new StringBuilder("<div style=\"font-family:Segoe UI,Arial,sans-serif;font-size:14px\"><table>");
foreach (var (label, value) in lines.Where(l => !string.IsNullOrWhiteSpace(l.Value)))
{
text.Append(label).Append(": ").Append(value).Append('\n');
html.Append("<tr><th style=\"text-align:left;padding-right:12px\">").Append(WebUtility.HtmlEncode(label)).Append("</th><td>")
.Append(WebUtility.HtmlEncode(value)).Append("</td></tr>");
}
html.Append("</table>");
if (!string.IsNullOrWhiteSpace(c.Description))
{
text.Append('\n').Append(c.Description).Append('\n');
html.Append("<p style=\"white-space:pre-wrap\">").Append(WebUtility.HtmlEncode(c.Description)).Append("</p>");
}
if (settings.PortalUrl is { } portal)
{
var link = new Uri(portal, $"portal/kunder/{c.CustomerId}");
text.Append('\n').Append(link).Append('\n');
html.Append("<p><a href=\"").Append(WebUtility.HtmlEncode(link.ToString())).Append("\">Åbn kunden i SQLInsight Hub</a></p>");
}
html.Append("<p style=\"color:#667085;font-size:12px\">Sendt af SQLInsight Hub. Svar på mailen går til kunden.</p></div>");
message.Body = new BodyBuilder { TextBody = text.ToString(), HtmlBody = html.ToString() }.ToMessageBody();
return message;
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Could not mail support about case {Reference} from {Customer}")]
private static partial void LogMailFailed(ILogger logger, Exception exception, string reference, string customer);
}

View file

@ -0,0 +1,153 @@
using System.Globalization;
using System.Text;
using SQLInsight.Contracts;
namespace SQLInsight.Hub.Support;
/// <summary>A support case as the report needs it.</summary>
public sealed record SupportReportCase(int Id, int CustomerId, string Customer, string Reference, string Title, string Source, DateTime CreatedAt, DateTime? ClosedAt)
{
public TimeSpan? ResolutionTime => ClosedAt - CreatedAt;
}
/// <summary>Key figures for one customer (or, for the total row, all customers).</summary>
public sealed record SupportReportCustomer(
int? CustomerId,
string Customer,
int Created,
int Closed,
int OpenNow,
TimeSpan? AverageResolution,
TimeSpan? MedianResolution,
TimeSpan? OldestOpenAge,
double? CustomerCreatedShare);
public sealed record SupportReportResult(
DateTime From,
DateTime To,
IReadOnlyList<SupportReportCustomer> Customers,
SupportReportCustomer Total,
IReadOnlyList<SupportReportCase> Cases);
/// <summary>The report period from the form: whole days, "til" included. Default: the last 90 days up to today.</summary>
public static class SupportReportPeriod
{
public const int DefaultDays = 90;
/// <returns>[From, To) in UTC.</returns>
public static (DateTime From, DateTime To) Resolve(DateTime? from, DateTime? to, DateTime now)
{
var end = (to?.Date ?? now.Date).AddDays(1);
var start = from?.Date ?? end.AddDays(-DefaultDays);
if (start >= end)
{
(start, end) = (end.AddDays(-1), start.AddDays(1));
}
return (DateTime.SpecifyKind(start, DateTimeKind.Utc), DateTime.SpecifyKind(end, DateTimeKind.Utc));
}
}
/// <summary>
/// The support report: how many cases were created and closed in a period, how many are open now, and how fast they
/// were solved. Resolution time is ClosedAt − CreatedAt of the cases closed in the period.
/// </summary>
public static class SupportReport
{
/// <param name="cases">Every case that may be relevant: created or closed in the period, or still open.</param>
/// <param name="from">Start of the period (inclusive, UTC).</param>
/// <param name="to">End of the period (exclusive, UTC).</param>
/// <param name="now">Now (UTC), for the age of open cases.</param>
public static SupportReportResult Build(IEnumerable<SupportReportCase> cases, DateTime from, DateTime to, DateTime now)
{
ArgumentNullException.ThrowIfNull(cases);
var all = cases.ToList();
bool InPeriod(DateTime t) => t >= from && t < to;
var listed = all
.Where(c => InPeriod(c.CreatedAt) || c.ClosedAt is { } closed && InPeriod(closed))
.OrderByDescending(c => c.CreatedAt)
.ToList();
var customers = all
.GroupBy(c => (c.CustomerId, c.Customer))
.Select(g => Figures(g.Key.CustomerId, g.Key.Customer, g.ToList(), InPeriod, now))
.Where(f => f.Created > 0 || f.Closed > 0 || f.OpenNow > 0)
.OrderByDescending(f => f.OpenNow).ThenByDescending(f => f.Created).ThenBy(f => f.Customer, StringComparer.CurrentCulture)
.ToList();
return new SupportReportResult(from, to, customers, Figures(null, "I alt", all, InPeriod, now), listed);
}
private static SupportReportCustomer Figures(int? customerId, string name, IReadOnlyList<SupportReportCase> cases, Func<DateTime, bool> inPeriod, DateTime now)
{
var created = cases.Where(c => inPeriod(c.CreatedAt)).ToList();
var resolutions = cases.Where(c => c.ClosedAt is { } closed && inPeriod(closed)).Select(c => c.ResolutionTime!.Value).ToList();
var open = cases.Where(c => c.ClosedAt is null).ToList();
return new SupportReportCustomer(
customerId,
name,
created.Count,
resolutions.Count,
open.Count,
resolutions.Count == 0 ? null : TimeSpan.FromTicks((long)resolutions.Average(r => r.Ticks)),
Median(resolutions),
open.Count == 0 ? null : now - open.Min(c => c.CreatedAt),
created.Count == 0 ? null : (double)created.Count(c => c.Source == SupportCaseSources.Customer) / created.Count);
}
public static TimeSpan? Median(IReadOnlyCollection<TimeSpan> values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Count == 0)
{
return null;
}
var sorted = values.OrderBy(v => v).ToList();
var middle = sorted.Count / 2;
return sorted.Count % 2 == 1 ? sorted[middle] : TimeSpan.FromTicks((sorted[middle - 1].Ticks + sorted[middle].Ticks) / 2);
}
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
/// <summary>Readable duration: "45 min", "6,5 t", "3,2 dage".</summary>
public static string Duration(TimeSpan? value) => value switch
{
null => "–",
{ TotalMinutes: < 60 } v => $"{Math.Max(0, (int)Math.Round(v.TotalMinutes))} min",
{ TotalHours: < 48 } v => $"{v.TotalHours.ToString("0.#", Danish)} t",
{ } v => $"{v.TotalDays.ToString("0.#", Danish)} dage",
};
/// <summary>
/// The cases as CSV for Excel in Danish: UTF-8 with BOM and semicolons. Times in UTC; resolution time in hours.
/// </summary>
public static byte[] Csv(SupportReportResult report)
{
ArgumentNullException.ThrowIfNull(report);
var csv = new StringBuilder();
csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Løsningstid (timer)\r\n");
foreach (var c in report.Cases)
{
csv.AppendJoin(';',
Field(c.Reference),
Field(c.Customer),
Field(c.Title),
Field(c.Source == SupportCaseSources.Customer ? "Kunde" : "SEOSoft"),
c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty);
csv.Append("\r\n");
}
return [.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes(csv.ToString())];
}
/// <summary>Quoted when needed; a leading =, +, - or @ is defused so Excel does not run it as a formula.</summary>
private static string Field(string value)
{
var text = value.Length > 0 && "=+-@".Contains(value[0], StringComparison.Ordinal) ? "'" + value : value;
return text.IndexOfAny([';', '"', '\r', '\n']) >= 0 ? "\"" + text.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"" : text;
}
}

View file

@ -167,3 +167,13 @@ input, select { font: inherit; padding: .5rem .65rem; border: 1px solid var(--bo
.nowrap { white-space: nowrap; } .nowrap { white-space: nowrap; }
td .inline-form { flex-wrap: nowrap; margin: 0; } td .inline-form { flex-wrap: nowrap; margin: 0; }
td .inline-form input { width: 8rem; min-width: 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

@ -0,0 +1,108 @@
using System.Net;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Hub.Data;
namespace SQLInsight.IntegrationTests;
/// <summary>Support cases in the portal: suggested case numbers and the support report.</summary>
public partial class HubPortalTests
{
private async Task<int> CustomerIdAsync(string customer)
{
await using var scope = hub.Factory.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<HubDbContext>().Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
}
[Fact]
public async Task New_case_suggests_the_next_number_and_refuses_a_taken_one()
{
var customer = $"Forslag {Guid.NewGuid():N}"[..22];
var finding = await SeedAsync(customer, hub.License(customer));
var id = await CustomerIdAsync(customer);
var client = await SignedInAsync();
Assert.Contains("value=\"SUP-000001\"", await client.GetStringAsync($"/portal/fund/{finding}"), StringComparison.Ordinal);
var created = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Telefonopkald"), ("description", "Ringede om backup"));
Assert.Equal($"/portal/kunder/{id}", created.Headers.Location?.OriginalString);
var page = await client.GetStringAsync($"/portal/kunder/{id}");
Assert.Contains("SUP-000001", page, StringComparison.Ordinal);
Assert.Contains("value=\"SUP-000002\"", page, StringComparison.Ordinal);
var duplicate = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", "SUP-000001"), ("title", "Igen"));
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
}
[Fact]
public async Task Support_report_shows_cases_and_exports_csv()
{
var customer = $"Rapport {Guid.NewGuid():N}"[..22];
await SeedAsync(customer, hub.License(customer));
var id = await CustomerIdAsync(customer);
var client = await SignedInAsync();
await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Til rapporten"));
var response = await client.GetAsync($"/portal/rapporter/support?kunde={id}");
var page = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, page);
var csv = await client.GetAsync($"/portal/actions/reports/support.csv?kunde={id}");
var bytes = await csv.Content.ReadAsByteArrayAsync();
Assert.Contains("Til rapporten", page, StringComparison.Ordinal);
Assert.Contains("median løsningstid", page, StringComparison.Ordinal);
Assert.Equal("text/csv", csv.Content.Headers.ContentType?.MediaType);
Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]);
Assert.Contains("SUP-000001;" + customer + ";Til rapporten;SEOSoft;", System.Text.Encoding.UTF8.GetString(bytes), StringComparison.Ordinal);
}
[Fact]
public async Task Customer_page_filters_findings_to_one_instance()
{
var customer = $"Instans {Guid.NewGuid():N}"[..22];
var license = hub.License(customer);
await SeedAsync(customer, license);
using (var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
{
Content = System.Net.Http.Json.JsonContent.Create(HubApiTests.Submission("sql02", DateTimeOffset.UtcNow, ("SQLI-CFG-001", "", "High"))),
})
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SQLInsight-License", license);
(await hub.Factory.CreateClient().SendAsync(request)).EnsureSuccessStatusCode();
}
var other = $"Fremmed {Guid.NewGuid():N}"[..22];
await SeedAsync(other, hub.License(other));
int id, sql02, foreign;
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
id = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
sql02 = await db.Instances.Where(i => i.CustomerId == id && i.InstanceKey == "sql02").Select(i => i.Id).SingleAsync();
foreign = await db.Instances.Where(i => i.Customer!.Name == other).Select(i => i.Id).SingleAsync();
}
var client = await SignedInAsync();
var all = await client.GetStringAsync($"/portal/kunder/{id}");
var filtered = await client.GetStringAsync($"/portal/kunder/{id}?instans={sql02}");
var ignored = await client.GetStringAsync($"/portal/kunder/{id}?instans={foreign}");
Assert.Contains("SQLI-DB-007", all, StringComparison.Ordinal);
Assert.Contains("SQLI-CFG-001", all, StringComparison.Ordinal);
Assert.Contains($"?instans={sql02}", all, StringComparison.Ordinal);
Assert.Contains("Aktive fund · sql02", filtered, StringComparison.Ordinal);
Assert.Contains("Vis alle instanser", filtered, StringComparison.Ordinal);
Assert.Contains("SQLI-CFG-001", filtered, StringComparison.Ordinal);
Assert.DoesNotContain("SQLI-DB-007", filtered, StringComparison.Ordinal);
Assert.Contains("SQLI-DB-007", ignored, StringComparison.Ordinal);
Assert.Contains("SQLI-CFG-001", ignored, StringComparison.Ordinal);
Assert.DoesNotContain("Vis alle instanser", ignored, StringComparison.Ordinal);
}
[Fact]
public async Task Support_report_csv_requires_login()
{
var response = await Client(hub.Factory).GetAsync("/portal/actions/reports/support.csv");
Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
}
}

View file

@ -0,0 +1,142 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Contracts;
using SQLInsight.Hub.Data;
using SQLInsight.Licensing;
namespace SQLInsight.IntegrationTests;
[Trait("Category", "Integration")]
public class HubSupportApiTests(HubFixture hub) : IClassFixture<HubFixture>
{
private static SupportCaseRequest Request(Guid? id = null, string title = "Backup fejler") =>
new(id ?? Guid.NewGuid(), title, "Siden i går", "Anna Andersen", "anna@kunde.dk", "12345678", @"KUNDE\anna");
private async Task<HttpResponseMessage> PostAsync(string license, SupportCaseRequest request)
{
using var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/cases") { Content = JsonContent.Create(request) };
message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
return await hub.Factory.CreateClient().SendAsync(message);
}
private async Task<SupportCaseDto> CreateAsync(string license, SupportCaseRequest request)
{
var response = await PostAsync(license, request);
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
return (await response.Content.ReadFromJsonAsync<SupportCaseDto>(JsonSerializerOptions.Web))!;
}
private static string Name(string prefix) => $"{prefix} {Guid.NewGuid():N}"[..24];
[Fact]
public async Task Customer_cases_are_numbered_SUP_000001_then_SUP_000002()
{
var customer = Name("Nummer");
var license = hub.License(customer);
var first = await CreateAsync(license, Request());
var second = await CreateAsync(license, Request());
Assert.Equal("SUP-000001", first.Reference);
Assert.Equal("SUP-000002", second.Reference);
Assert.Equal(SupportCaseSources.Customer, first.Source);
Assert.True(first.IsOpen);
await using var scope = hub.Factory.Services.CreateAsyncScope();
var stored = await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.SingleAsync(c => c.Customer!.Name == customer && c.Reference == "SUP-000001");
Assert.Equal(("Anna Andersen", "anna@kunde.dk", @"KUNDE\anna"), (stored.ContactName, stored.ContactEmail, stored.RequestedBy));
}
[Fact]
public async Task A_retried_request_returns_the_same_case()
{
var customer = Name("Igen");
var license = hub.License(customer);
var request = Request();
var first = await CreateAsync(license, request);
var again = await CreateAsync(license, request);
Assert.Equal(first.Reference, again.Reference);
await using var scope = hub.Factory.Services.CreateAsyncScope();
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.CountAsync(c => c.Customer!.Name == customer));
}
[Fact]
public async Task Each_customer_has_its_own_sequence()
{
await CreateAsync(hub.License(Name("Første")), Request());
var other = await CreateAsync(hub.License(Name("Anden")), Request());
Assert.Equal("SUP-000001", other.Reference);
}
[Fact]
public async Task Community_license_cannot_create_cases()
{
var response = await PostAsync(hub.License(Name("Gratis"), LicensePlan.Community), Request());
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Invalid_request_is_rejected()
{
var response = await PostAsync(hub.License(Name("Ugyldig")), Request() with { ContactEmail = "ingen-snabel-a" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Customer_sees_its_own_cases_including_those_support_created()
{
var customer = Name("Liste");
var license = hub.License(customer);
await CreateAsync(license, Request(title: "Fra kunden"));
await CreateAsync(hub.License(Name("Fremmed")), Request(title: "Andens sag"));
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
var customerId = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
await SQLInsight.Hub.Support.SupportCaseStore.AddAsync(db, new SupportCase
{
CustomerId = customerId, Reference = string.Empty, Title = "Fra SEOSoft", CreatedAt = DateTime.UtcNow, IsOpen = false, ClosedAt = DateTime.UtcNow,
});
}
using var message = new HttpRequestMessage(HttpMethod.Get, "/api/v1/cases");
message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
var cases = (await (await hub.Factory.CreateClient().SendAsync(message)).Content.ReadFromJsonAsync<List<SupportCaseDto>>(JsonSerializerOptions.Web))!;
Assert.Equal(["Fra SEOSoft", "Fra kunden"], cases.Select(c => c.Title));
Assert.Equal("SUP-000002", cases[0].Reference);
Assert.False(cases[0].IsOpen);
}
[Fact]
public async Task Case_created_from_a_finding_is_linked_to_it()
{
var customer = Name("Fund");
var license = hub.License(customer);
using (var submit = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
{
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-DB-007", "database: Sales", "Critical"))),
})
{
submit.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
(await hub.Factory.CreateClient().SendAsync(submit)).EnsureSuccessStatusCode();
}
var created = await CreateAsync(license, Request() with { InstanceName = "sql01", RuleId = "SQLI-DB-007", Subject = "database: Sales" });
await using var scope = hub.Factory.Services.CreateAsyncScope();
var finding = await scope.ServiceProvider.GetRequiredService<HubDbContext>().Findings.Include(f => f.SupportCase)
.SingleAsync(f => f.Instance!.Customer!.Name == customer);
Assert.Equal(created.Reference, finding.SupportCase?.Reference);
}
}

View file

@ -0,0 +1,99 @@
using SQLInsight.Contracts;
using SQLInsight.Hub.Support;
namespace SQLInsight.IntegrationTests;
/// <summary>Pure logic behind support cases: no database, so these run with the unit tests.</summary>
public class HubSupportLogicTests
{
[Fact]
public void First_case_number_is_SUP_000001() => Assert.Equal("SUP-000001", CaseNumbers.Next([]));
[Fact]
public void Next_number_follows_the_highest_SUP_number() =>
Assert.Equal("SUP-000013", CaseNumbers.Next(["SUP-000002", "sup-000012", "SUP-000009"]));
[Fact]
public void Hand_typed_references_do_not_affect_the_sequence() =>
Assert.Equal("SUP-000004", CaseNumbers.Next(["SUP-000003", "INC-99999", "SUP-12A", "Sag fra telefon"]));
[Fact]
public void Old_short_numbers_continue_in_six_digits() => Assert.Equal("SUP-001235", CaseNumbers.Next(["SUP-1234"]));
private static readonly DateTime T0 = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
private static SupportReportCase Case(int id, int customer, double createdDays, double? closedAfterHours, string source = SupportCaseSources.Staff) =>
new(id, customer, $"Kunde {customer}", $"SUP-{id:D6}", "Titel", source, T0.AddDays(createdDays), closedAfterHours is { } h ? T0.AddDays(createdDays).AddHours(h) : null);
[Fact]
public void Report_counts_created_closed_and_open_and_computes_average_and_median()
{
var cases = new[]
{
Case(1, 1, 1, 2, SupportCaseSources.Customer),
Case(2, 1, 2, 4),
Case(3, 1, 3, 30),
Case(4, 1, 4, null, SupportCaseSources.Customer),
Case(5, 2, -40, 1), // created and closed before the period: not counted
Case(6, 2, -40, null), // created before, still open: open now
};
var report = SupportReport.Build(cases, T0, T0.AddDays(30), T0.AddDays(10));
var first = report.Customers.Single(c => c.CustomerId == 1);
var second = report.Customers.Single(c => c.CustomerId == 2);
Assert.Equal((4, 3, 1), (first.Created, first.Closed, first.OpenNow));
Assert.Equal(TimeSpan.FromHours(12), first.AverageResolution);
Assert.Equal(TimeSpan.FromHours(4), first.MedianResolution);
Assert.Equal(TimeSpan.FromDays(6), first.OldestOpenAge);
Assert.Equal(0.5, first.CustomerCreatedShare);
Assert.Equal((0, 0, 1), (second.Created, second.Closed, second.OpenNow));
Assert.Equal(TimeSpan.FromDays(50), second.OldestOpenAge);
Assert.Equal((4, 3, 2), (report.Total.Created, report.Total.Closed, report.Total.OpenNow));
Assert.Equal([4, 3, 2, 1], report.Cases.Select(c => c.Id));
}
[Fact]
public void Median_of_an_even_count_is_the_mean_of_the_middle_two() =>
Assert.Equal(TimeSpan.FromHours(3), SupportReport.Median([TimeSpan.FromHours(1), TimeSpan.FromHours(2), TimeSpan.FromHours(4), TimeSpan.FromHours(9)]));
[Theory]
[InlineData(30, "30 min")]
[InlineData(90, "1,5 t")]
[InlineData(4320, "3 dage")]
public void Durations_are_readable(double minutes, string expected) => Assert.Equal(expected, SupportReport.Duration(TimeSpan.FromMinutes(minutes)));
[Fact]
public void Default_period_is_the_last_90_days_including_today()
{
var (from, to) = SupportReportPeriod.Resolve(null, null, new DateTime(2026, 10, 1, 14, 0, 0, DateTimeKind.Utc));
Assert.Equal(new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc), to);
Assert.Equal(to.AddDays(-90), from);
}
[Fact]
public void Csv_has_bom_semicolons_and_defuses_formulas()
{
var report = SupportReport.Build([Case(1, 1, 1, 6) with { Title = "=HYPERLINK(\"x\"); ond" }], T0, T0.AddDays(30), T0.AddDays(10));
var bytes = SupportReport.Csv(report);
var text = System.Text.Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3);
Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]);
Assert.StartsWith("Sag;Kunde;Titel;Kilde;", text, StringComparison.Ordinal);
Assert.Contains("\"'=HYPERLINK(\"\"x\"\"); ond\"", text, StringComparison.Ordinal);
Assert.Contains(";6,0\r\n", text, StringComparison.Ordinal);
}
[Fact]
public void Request_validation_rejects_missing_contact_and_long_titles()
{
var valid = new SupportCaseRequest(Guid.NewGuid(), "Langsom server", null, "Anna", "anna@kunde.dk", null, null);
Assert.Null(valid.Validate());
Assert.NotNull((valid with { ContactEmail = "anna" }).Validate());
Assert.NotNull((valid with { Title = new string('x', 201) }).Validate());
Assert.NotNull((valid with { ClientRequestId = Guid.Empty }).Validate());
}
}