Support cases through the API: create, read and close
- POST/GET /api/v1/support-cases[/{id}] and POST .../{id}/close in
SQLInsight Web, sharing the /support form's logic: same checks, finding
prefill without sensitive evidence, Idempotency-Key as the request id,
202 Accepted with Location, problem+json 403 without the support:write
scope or a support license. RequestedBy is the Windows user or
token:<name>.
- Closing: "Luk sag" on /support and the API record CloseRequested
(migration 0012, column-level UPDATE for the read-only web login); the
agent sends it to the hub with backoff and sets Closed when confirmed;
a pending case is closed once it has a number; an older hub list never
turns CloseRequested back to Open.
- Hub: POST /api/v1/cases/{reference}/close for the license's own cases
(others 404), idempotent, ClosedBy/ClosedComment (EF migration
CaseClosedBy). Portal closing records "SEOSoft: <user>"; the customer
page shows who closed and why; the support report and CSV get "Lukket af".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
30d89acbd7
commit
f3551b1bf0
26 changed files with 1417 additions and 38 deletions
|
|
@ -113,3 +113,7 @@ dotnet SQLInsight.Hub.dll delete-customer "Kunde A/S" --confirm
|
|||
```
|
||||
|
||||
Alle kundens instanser, fund, regelstatus, modtagelser og supportsager slettes. Licenserne spærres, så agenterne ikke kan sende igen, og kunden omdøbes til "Slettet kunde #id". Se også [databehandling.md](databehandling.md).
|
||||
|
||||
### Kunden lukker en sag
|
||||
|
||||
`POST /api/v1/cases/{reference}/close` (licens som Authorization, kræver support i licensen) lukker en af licensens egne sager; andres sager giver 404. Body `{ comment?, requestedBy? }`. Sagen får `ClosedBy = "Kunde: <bruger>"` og kommentaren; lukning i portalen giver `ClosedBy = "SEOSoft: <medarbejder>"`. Kaldet er idempotent. Agenten sender lukninger, som kunden har bedt om i SQLInsight Web, og en senere liste fra hubben sætter ikke en ventende lukning tilbage til åben.
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ Alt, hvad webbrugerfladen viser, kan hentes som JSON under `/api/v1/` med de sam
|
|||
| `GET /api/v1/instances/{navn}/privileges?days=90` | Rettigheder med udfoldede Windows-grupper, sa, adgangskodepolitik, default trace og ændringer |
|
||||
| `GET /api/v1/privileges?days=90` | Det samme for alle instanser |
|
||||
| `GET /api/v1/instances/{navn}/wait-types/{ventetype}?from=&to=` | Forklaring, udvikling og de sætninger, der ventede |
|
||||
| `POST /api/v1/support-cases`, `GET /api/v1/support-cases[/{id}]`, `POST /api/v1/support-cases/{id}/close` | Supportsager: se [support.md](support.md) (kræver scope `support:write` for at skrive) |
|
||||
| `GET /metrics` | Prometheus-format |
|
||||
|
||||
**Følsomme data:** forespørgselstekster, logins, personnavne, værts- og programnavne og tjenestekonti vises som `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er `true`. Det gælder både fund, rapporter i JSON og sidernes data. PDF-rapporterne indeholder de samme data som i webbrugerfladen.
|
||||
|
|
|
|||
|
|
@ -23,3 +23,24 @@ Siden **Support** viser alle jeres sager, også dem SEOSoft har oprettet for jer
|
|||
Webbrugerfladens login har kun `db_datareader` på repositoriet. For at kunne oprette en sag har rollen `public` i repository-databasen fået `INSERT` på den ene tabel `insight.SupportCases` (migration `0005_support_cases.sql`). Kun repositoriets egne brugere findes i den database.
|
||||
|
||||
Hvad der sendes til SEOSoft, står i [databehandling.md](databehandling.md).
|
||||
|
||||
## Supportsager via API'et
|
||||
|
||||
Sager kan oprettes, læses og lukkes fra fx et ITSM-system (ServiceNow, TOPdesk) eller PowerShell. Det kræver det samme som formularen (Professional/Enterprise og deling med SQLInsight Hub), og et token med scope `support:write` (oprettes med `SQLInsight.Web.exe new-api-token itsm --scope read,support:write`). Windows-brugere med adgang til webbrugerfladen må det samme.
|
||||
|
||||
| Kald | Svar |
|
||||
|---|---|
|
||||
| `POST /api/v1/support-cases` | Body `{ title, description, contactName, contactEmail, contactPhone?, findingId? }`. Med `findingId` udfyldes titel og beskrivelse med fundets ikke-følsomme oplysninger. Svar 202 med lokalt `id` og status `Pending`; sagsnummeret (SUP-…) kommer, når agenten har talt med hubben. Header `Idempotency-Key` giver samme sag ved gentagelse. 403 (problem+json) uden scope eller licens |
|
||||
| `GET /api/v1/support-cases?status=&from=&to=&limit=` | Sagerne, nyeste først (sider med `X-Continuation`) |
|
||||
| `GET /api/v1/support-cases/{id}` | Status (`Pending`, `Open`, `CloseRequested`, `Closed`, `Failed`), sagsnummer, oprettet/lukket, lukket af, fejl |
|
||||
| `POST /api/v1/support-cases/{id}/close` | Body `{ comment? }`. Status `CloseRequested`, indtil hubben har bekræftet, så `Closed`. Allerede lukket: 200. En sag uden sagsnummer endnu lukkes, så snart den er oprettet |
|
||||
|
||||
Sager kan også lukkes med knappen **Luk sag** på siden Support. SEOSoft ser i portalen, hvem der lukkede sagen (kunde eller SEOSoft) og kommentaren, og supportrapporten har kolonnen "Lukket af".
|
||||
|
||||
```powershell
|
||||
$h = @{ Authorization = "Bearer $token"; "Idempotency-Key" = "INC0012345" }
|
||||
$sag = Invoke-RestMethod https://sqlinsight.domæne.local/api/v1/support-cases -Method Post -Headers $h -ContentType application/json `
|
||||
-Body (@{ contactName = "Servicedesk"; contactEmail = "servicedesk@firma.dk"; findingId = 4711 } | ConvertTo-Json)
|
||||
# senere: sagsnummer og status
|
||||
Invoke-RestMethod "https://sqlinsight.domæne.local/api/v1/support-cases/$($sag.id)" -Headers $h
|
||||
```
|
||||
|
|
|
|||
|
|
@ -163,6 +163,40 @@ public sealed partial class HubSyncService(
|
|||
}
|
||||
}
|
||||
|
||||
// Closings requested in SQLInsight Web, for cases the hub has given a number.
|
||||
foreach (var item in await repository.GetDueSupportClosesAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
try
|
||||
{
|
||||
var body = new Contracts.SupportCaseCloseRequest(item.CloseComment, item.CloseRequestedBy);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Url!, $"api/v1/cases/{Uri.EscapeDataString(item.Reference!)}/close"))
|
||||
{
|
||||
Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body, HubOutbox.Json), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License);
|
||||
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode && System.Text.Json.JsonSerializer.Deserialize<Contracts.SupportCaseDto>(text, HubOutbox.Json) is { } dto)
|
||||
{
|
||||
await repository.MarkSupportClosedAsync(item.CaseId, dto.ClosedAt?.UtcDateTime, dto.ClosedBy, now, cancellationToken).ConfigureAwait(false);
|
||||
created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var error = $"{(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(text)}";
|
||||
var permanent = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
|
||||
or HttpStatusCode.NotFound or HttpStatusCode.UnprocessableEntity;
|
||||
await repository.MarkSupportCloseFailedAsync(item.CaseId, error, now, permanent ? null : now + Backoff(item.CloseAttempts), cancellationToken).ConfigureAwait(false);
|
||||
LogCaseRejected(logger, item.Title, error);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await repository.MarkSupportCloseFailedAsync(item.CaseId, ex.Message, now, now + Backoff(item.CloseAttempts), cancellationToken).ConfigureAwait(false);
|
||||
LogCaseUnreachable(logger, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (created == 0 && _casesFetchedAt is { } fetched && time.GetUtcNow() - fetched < CasesInterval)
|
||||
{
|
||||
return;
|
||||
|
|
@ -181,7 +215,7 @@ public sealed partial class HubSyncService(
|
|||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var cases = System.Text.Json.JsonSerializer.Deserialize<List<Contracts.SupportCaseDto>>(json, HubOutbox.Json) ?? [];
|
||||
await repository.SyncSupportCasesAsync(
|
||||
cases.Select(c => new HubSupportCase(c.Reference, c.Title, c.IsOpen, c.CreatedAt.UtcDateTime, c.ClosedAt?.UtcDateTime, c.Source, c.ClientRequestId)),
|
||||
cases.Select(c => new HubSupportCase(c.Reference, c.Title, c.IsOpen, c.CreatedAt.UtcDateTime, c.ClosedAt?.UtcDateTime, c.Source, c.ClientRequestId, c.ClosedBy)),
|
||||
time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
_casesFetchedAt = time.GetUtcNow();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ public sealed record SupportCaseDto(
|
|||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ClosedAt,
|
||||
string Source,
|
||||
Guid? ClientRequestId);
|
||||
Guid? ClientRequestId,
|
||||
string? ClosedBy = null,
|
||||
string? ClosedComment = null);
|
||||
|
||||
/// <summary>Who created a support case.</summary>
|
||||
public static class SupportCaseSources
|
||||
|
|
@ -81,3 +83,15 @@ public static class SupportCaseSources
|
|||
/// <summary>The customer, in SQLInsight Web.</summary>
|
||||
public const string Customer = "Customer";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The customer closes a case (POST /api/v1/cases/{reference}/close), from SQLInsight Web or its API. Closing a closed
|
||||
/// case changes nothing.
|
||||
/// </summary>
|
||||
public sealed record SupportCaseCloseRequest(string? Comment, string? RequestedBy)
|
||||
{
|
||||
public const int MaxComment = 1000;
|
||||
|
||||
public string? Validate() => Comment?.Length > MaxComment ? $"Kommentaren må højst være {MaxComment} tegn."
|
||||
: RequestedBy?.Length > SupportCaseRequest.MaxRequestedBy ? "Brugernavnet er for langt." : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ namespace SQLInsight.Hub.Api;
|
|||
/// 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.
|
||||
/// POST /api/v1/cases/{reference}/close closes one of the customer's own cases (idempotent).
|
||||
/// </summary>
|
||||
public static class CasesEndpoint
|
||||
{
|
||||
|
|
@ -20,6 +21,7 @@ public static class CasesEndpoint
|
|||
{
|
||||
app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous();
|
||||
app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous();
|
||||
app.MapPost("/api/v1/cases/{reference}/close", CloseAsync).DisableAntiforgery().AllowAnonymous();
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CreateAsync(
|
||||
|
|
@ -138,5 +140,57 @@ public static class CasesEndpoint
|
|||
return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList());
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CloseAsync(
|
||||
string reference, 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.");
|
||||
}
|
||||
|
||||
SupportCaseCloseRequest body;
|
||||
try
|
||||
{
|
||||
body = request.ContentLength is null or 0
|
||||
? new SupportCaseCloseRequest(null, null)
|
||||
: await JsonSerializer.DeserializeAsync<SupportCaseCloseRequest>(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false)
|
||||
?? new SupportCaseCloseRequest(null, null);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return FindingsEndpoint.Problem(400, "Ugyldig JSON", ex.Message);
|
||||
}
|
||||
|
||||
if (body.Validate() is { } invalid)
|
||||
{
|
||||
return FindingsEndpoint.Problem(422, "Ugyldig lukning", invalid);
|
||||
}
|
||||
|
||||
// Only the license's own customer's cases: anyone else's is simply not found.
|
||||
var supportCase = await db.SupportCases
|
||||
.SingleOrDefaultAsync(c => c.Customer!.Name == license.Customer && c.Reference == reference, cancellationToken).ConfigureAwait(false);
|
||||
if (supportCase is null)
|
||||
{
|
||||
return FindingsEndpoint.Problem(404, "Ukendt sag", $"Sagen {reference} findes ikke.");
|
||||
}
|
||||
|
||||
if (supportCase.IsOpen)
|
||||
{
|
||||
supportCase.IsOpen = false;
|
||||
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
|
||||
supportCase.ClosedBy = "Kunde: " + (string.IsNullOrWhiteSpace(body.RequestedBy) ? "ukendt" : body.RequestedBy.Trim());
|
||||
supportCase.ClosedComment = Trimmed(body.Comment);
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Results.Ok(SupportCaseStore.ToDto(supportCase));
|
||||
}
|
||||
|
||||
private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,12 @@ public sealed class SupportCase
|
|||
public bool IsOpen { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
|
||||
/// <summary>Who closed it: "Kunde: DOMÆNE\bruger" (from SQLInsight Web or its API) or "SEOSoft: navn" (the portal).</summary>
|
||||
public string? ClosedBy { get; set; }
|
||||
|
||||
/// <summary>The comment given when the case was closed.</summary>
|
||||
public string? ClosedComment { get; set; }
|
||||
public List<HubFinding> Findings { get; } = [];
|
||||
|
||||
/// <summary>Who created it: <see cref="Contracts.SupportCaseSources.Staff"/> or <see cref="Contracts.SupportCaseSources.Customer"/>.</summary>
|
||||
|
|
|
|||
594
src/SQLInsight.Hub/Data/Migrations/20261002072808_CaseClosedBy.Designer.cs
generated
Normal file
594
src/SQLInsight.Hub/Data/Migrations/20261002072808_CaseClosedBy.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
// <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("20261002072808_CaseClosedBy")]
|
||||
partial class CaseClosedBy
|
||||
{
|
||||
/// <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>("ClosedBy")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("closed_by");
|
||||
|
||||
b.Property<string>("ClosedComment")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("closed_comment");
|
||||
|
||||
b.Property<string>("ContactEmail")
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)")
|
||||
.HasColumnName("contact_email");
|
||||
|
||||
b.Property<string>("ContactName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("contact_name");
|
||||
|
||||
b.Property<string>("ContactPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("contact_phone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8000)
|
||||
.HasColumnType("character varying(8000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsOpen")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_open");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("note");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reference");
|
||||
|
||||
b.Property<string>("RequestedBy")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("requested_by");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasDefaultValue("Staff")
|
||||
.HasColumnName("source");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_support_cases");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("ix_support_cases_created_at");
|
||||
|
||||
b.HasIndex("CustomerId", "ClientRequestId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_support_cases_customer_id_client_request_id")
|
||||
.HasFilter("client_request_id IS NOT NULL");
|
||||
|
||||
b.HasIndex("CustomerId", "Reference")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_support_cases_customer_id_reference");
|
||||
|
||||
b.ToTable("support_cases", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
|
||||
{
|
||||
b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
|
||||
.WithMany("Findings")
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_findings_instances_instance_id");
|
||||
|
||||
b.HasOne("SQLInsight.Hub.Data.SupportCase", "SupportCase")
|
||||
.WithMany("Findings")
|
||||
.HasForeignKey("SupportCaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_findings_support_cases_support_case_id");
|
||||
|
||||
b.Navigation("Instance");
|
||||
|
||||
b.Navigation("SupportCase");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
|
||||
{
|
||||
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_instances_customers_customer_id");
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.InstanceVersionChange", b =>
|
||||
{
|
||||
b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_version_changes_instances_instance_id");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
|
||||
{
|
||||
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
|
||||
.WithMany("Licenses")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_licenses_customers_customer_id");
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
|
||||
{
|
||||
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
|
||||
.WithMany("SupportCases")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_support_cases_customers_customer_id");
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
|
||||
b.Navigation("Licenses");
|
||||
|
||||
b.Navigation("SupportCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
|
||||
{
|
||||
b.Navigation("Findings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
|
||||
{
|
||||
b.Navigation("Findings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SQLInsight.Hub.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CaseClosedBy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "closed_by",
|
||||
table: "support_cases",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "closed_comment",
|
||||
table: "support_cases",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "closed_by",
|
||||
table: "support_cases");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "closed_comment",
|
||||
table: "support_cases");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -414,6 +414,14 @@ namespace SQLInsight.Hub.Data.Migrations
|
|||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("closed_at");
|
||||
|
||||
b.Property<string>("ClosedBy")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("closed_by");
|
||||
|
||||
b.Property<string>("ClosedComment")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("closed_comment");
|
||||
|
||||
b.Property<string>("ContactEmail")
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)")
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
else
|
||||
{
|
||||
<table>
|
||||
<thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
|
||||
<thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th><th>Lukket af</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
|
||||
{
|
||||
|
|
@ -81,6 +81,7 @@
|
|||
<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>
|
||||
<td>@c.ClosedBy@(c.ClosedComment is { } comment ? $": {comment}" : "")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
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>
|
||||
<thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th>Kilde</th><th>Oprettet</th><th>Lukket</th><th>Lukket af</th><th>Løsningstid</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _report.Cases)
|
||||
{
|
||||
|
|
@ -93,6 +93,7 @@
|
|||
<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 title="@c.ClosedBy">@c.ClosedByWhom</td>
|
||||
<td class="nowrap">@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))</td>
|
||||
</tr>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public static class PortalEndpoints
|
|||
await db.SaveChangesAsync(ct);
|
||||
return Results.Redirect($"/portal/fund/{id}");
|
||||
});
|
||||
portal.MapPost("/cases/{id:int}/close", async (int id, HubDbContext db, TimeProvider time, CancellationToken ct) =>
|
||||
portal.MapPost("/cases/{id:int}/close", async (int id, HttpContext http, HubDbContext db, TimeProvider time, CancellationToken ct) =>
|
||||
{
|
||||
var supportCase = await db.SupportCases.FindAsync([id], ct);
|
||||
if (supportCase is null)
|
||||
|
|
@ -49,8 +49,13 @@ public static class PortalEndpoints
|
|||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (supportCase.IsOpen)
|
||||
{
|
||||
supportCase.IsOpen = false;
|
||||
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
|
||||
supportCase.ClosedBy = "SEOSoft: " + (http.User.Identity?.Name ?? "ukendt");
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Redirect("/portal/sager");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -110,11 +110,12 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time)
|
|||
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 })
|
||||
.Select(c => new { c.Id, c.CustomerId, Customer = c.Customer!.Name, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen, c.ClosedAt, c.ClosedBy })
|
||||
.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)),
|
||||
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,
|
||||
c.IsOpen ? null : c.ClosedBy)),
|
||||
from, to, Now);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ public static class SupportCaseStore
|
|||
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);
|
||||
return new SupportCaseDto(c.Reference, c.Title, c.IsOpen, Utc(c.CreatedAt), c.ClosedAt is { } closed ? Utc(closed) : null, c.Source, c.ClientRequestId,
|
||||
c.ClosedBy, c.ClosedComment);
|
||||
}
|
||||
|
||||
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
|
||||
|
|
|
|||
|
|
@ -5,9 +5,14 @@ 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)
|
||||
/// <param name="ClosedBy">"Kunde: …" or "SEOSoft: …"; null for open cases and cases closed before it was recorded.</param>
|
||||
public sealed record SupportReportCase(int Id, int CustomerId, string Customer, string Reference, string Title, string Source, DateTime CreatedAt, DateTime? ClosedAt,
|
||||
string? ClosedBy = null)
|
||||
{
|
||||
public TimeSpan? ResolutionTime => ClosedAt - CreatedAt;
|
||||
|
||||
/// <summary>"Kunde" when the customer closed it, "SEOSoft" when staff did, empty while open.</summary>
|
||||
public string ClosedByWhom => ClosedAt is null ? string.Empty : ClosedBy?.StartsWith("Kunde", StringComparison.Ordinal) == true ? "Kunde" : "SEOSoft";
|
||||
}
|
||||
|
||||
/// <summary>Key figures for one customer (or, for the total row, all customers).</summary>
|
||||
|
|
@ -127,7 +132,7 @@ public static class SupportReport
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
var csv = new StringBuilder();
|
||||
csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Løsningstid (timer)\r\n");
|
||||
csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Lukket af;Løsningstid (timer)\r\n");
|
||||
foreach (var c in report.Cases)
|
||||
{
|
||||
csv.AppendJoin(';',
|
||||
|
|
@ -137,6 +142,7 @@ public static class SupportReport
|
|||
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,
|
||||
Field(c.ClosedByWhom),
|
||||
c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty);
|
||||
csv.Append("\r\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ public static class SupportCaseStatus
|
|||
public const string Pending = "Pending";
|
||||
|
||||
public const string Open = "Open";
|
||||
/// <summary>Closing was requested in SQLInsight Web; the agent has not had it confirmed by the hub yet.</summary>
|
||||
public const string CloseRequested = "CloseRequested";
|
||||
|
||||
public const string Closed = "Closed";
|
||||
|
||||
/// <summary>The hub refused the request (e.g. no support in the license); see LastError.</summary>
|
||||
|
|
@ -37,6 +40,11 @@ public sealed record SupportCaseRow
|
|||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? SentAt { get; init; }
|
||||
public DateTime? ClosedAt { get; init; }
|
||||
public DateTime? CloseRequestedAt { get; init; }
|
||||
public string? CloseRequestedBy { get; init; }
|
||||
public string? CloseComment { get; init; }
|
||||
public int CloseAttempts { get; init; }
|
||||
public string? ClosedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A request filed in SQLInsight Web.</summary>
|
||||
|
|
@ -54,7 +62,8 @@ public sealed record NewSupportRequest(
|
|||
string? Subject = null);
|
||||
|
||||
/// <summary>A case as the hub reports it.</summary>
|
||||
public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId);
|
||||
public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId,
|
||||
string? ClosedBy = null);
|
||||
|
||||
public sealed partial class InsightRepository
|
||||
{
|
||||
|
|
@ -102,7 +111,8 @@ public sealed partial class InsightRepository
|
|||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync("""
|
||||
UPDATE insight.SupportCases
|
||||
SET Reference = @reference, Status = CASE WHEN @isOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @closedAt,
|
||||
SET Reference = @reference, ClosedAt = @closedAt,
|
||||
Status = CASE WHEN @isOpen = 0 THEN 'Closed' WHEN CloseRequestedAt IS NOT NULL THEN 'CloseRequested' ELSE 'Open' END,
|
||||
SentAt = @now, Attempts = Attempts + 1, LastError = NULL, UpdatedAt = @now
|
||||
WHERE CaseId = @caseId;
|
||||
""", new { caseId, reference, isOpen, closedAt, now = Utc(now) }).ConfigureAwait(false);
|
||||
|
|
@ -132,13 +142,15 @@ public sealed partial class InsightRepository
|
|||
{
|
||||
await connection.ExecuteAsync("""
|
||||
UPDATE insight.SupportCases
|
||||
SET Reference = @Reference, Status = CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @ClosedAt,
|
||||
SET Reference = @Reference, ClosedAt = @ClosedAt, ClosedBy = COALESCE(@ClosedBy, ClosedBy),
|
||||
-- A close request not yet confirmed must not be turned back into Open by an older list from the hub.
|
||||
Status = CASE WHEN @IsOpen = 0 THEN 'Closed' WHEN Status = 'CloseRequested' THEN Status ELSE 'Open' END,
|
||||
Title = CASE WHEN ClientRequestId IS NULL THEN @Title ELSE Title END, LastError = NULL, UpdatedAt = @now
|
||||
WHERE (@ClientRequestId IS NOT NULL AND ClientRequestId = @ClientRequestId) OR Reference = @Reference;
|
||||
IF @@ROWCOUNT = 0
|
||||
INSERT insight.SupportCases (ClientRequestId, Reference, Title, Source, Status, CreatedAt, ClosedAt, SentAt, UpdatedAt)
|
||||
VALUES (@ClientRequestId, @Reference, @Title, @Source, CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, @CreatedAt, @ClosedAt, @now, @now);
|
||||
""", new { c.Reference, c.Title, c.IsOpen, c.CreatedAt, c.ClosedAt, c.Source, c.ClientRequestId, now = Utc(now) }).ConfigureAwait(false);
|
||||
""", new { c.Reference, c.Title, c.IsOpen, c.CreatedAt, c.ClosedAt, c.Source, c.ClientRequestId, c.ClosedBy, now = Utc(now) }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
120
src/SQLInsight.Repository/InsightRepository.SupportApi.cs
Normal file
120
src/SQLInsight.Repository/InsightRepository.SupportApi.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using Dapper;
|
||||
|
||||
namespace SQLInsight.Repository;
|
||||
|
||||
public enum SupportCloseOutcome
|
||||
{
|
||||
NotFound,
|
||||
|
||||
/// <summary>Closed already: nothing to do.</summary>
|
||||
AlreadyClosed,
|
||||
|
||||
/// <summary>Recorded; the agent sends it (at once, or once the hub has created the case).</summary>
|
||||
Requested,
|
||||
|
||||
/// <summary>The hub refused the case; it cannot be closed.</summary>
|
||||
Failed,
|
||||
}
|
||||
|
||||
public sealed partial class InsightRepository
|
||||
{
|
||||
public async Task<SupportCaseRow?> GetSupportCaseAsync(long caseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await connection.QuerySingleOrDefaultAsync<SupportCaseRow>("SELECT * FROM insight.SupportCases WHERE CaseId = @caseId;", new { caseId })
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<SupportCaseRow?> GetSupportCaseByRequestIdAsync(Guid clientRequestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await connection.QuerySingleOrDefaultAsync<SupportCaseRow>(
|
||||
"SELECT * FROM insight.SupportCases WHERE ClientRequestId = @clientRequestId;", new { clientRequestId }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>The customer's cases, newest first: optionally by status and created in [from, to]; one page and whether there are more.</summary>
|
||||
public async Task<(IReadOnlyList<SupportCaseRow> Cases, bool More)> SearchSupportCasesAsync(string? status, DateTimeOffset? from, DateTimeOffset? to,
|
||||
int offset, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rows = (await connection.QueryAsync<SupportCaseRow>("""
|
||||
SELECT * FROM insight.SupportCases
|
||||
WHERE (@status IS NULL OR Status = @status) AND (@from IS NULL OR CreatedAt >= @from) AND (@to IS NULL OR CreatedAt <= @to)
|
||||
ORDER BY CreatedAt DESC, CaseId DESC
|
||||
OFFSET @offset ROWS FETCH NEXT @take ROWS ONLY;
|
||||
""", new { status, from = from?.UtcDateTime, to = to?.UtcDateTime, offset = Math.Max(0, offset), take = limit + 1 }).ConfigureAwait(false)).ToList();
|
||||
return (rows.Take(limit).ToList(), rows.Count > limit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the customer wants a case closed (SQLInsight Web: UPDATE on the close columns only, see
|
||||
/// 0012_support_close.sql). An open case becomes CloseRequested; a pending one keeps its status and is closed once
|
||||
/// the hub has created it.
|
||||
/// </summary>
|
||||
public async Task<SupportCloseOutcome> RequestSupportCloseAsync(long caseId, string? requestedBy, string? comment, DateTimeOffset now,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var status = await connection.QuerySingleOrDefaultAsync<string>("SELECT Status FROM insight.SupportCases WHERE CaseId = @caseId;", new { caseId })
|
||||
.ConfigureAwait(false);
|
||||
switch (status)
|
||||
{
|
||||
case null:
|
||||
return SupportCloseOutcome.NotFound;
|
||||
case SupportCaseStatus.Closed:
|
||||
return SupportCloseOutcome.AlreadyClosed;
|
||||
case SupportCaseStatus.Failed:
|
||||
return SupportCloseOutcome.Failed;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync("""
|
||||
UPDATE insight.SupportCases
|
||||
SET CloseRequestedAt = COALESCE(CloseRequestedAt, @now), CloseRequestedBy = COALESCE(CloseRequestedBy, @requestedBy),
|
||||
CloseComment = COALESCE(@comment, CloseComment), CloseNextAttemptAt = @now,
|
||||
Status = CASE WHEN Status = 'Open' THEN 'CloseRequested' ELSE Status END, UpdatedAt = @now
|
||||
WHERE CaseId = @caseId AND Status IN ('Pending', 'Open', 'CloseRequested');
|
||||
""", new { caseId, requestedBy = Cut(requestedBy, 256), comment = Cut(comment, 1000), now = Utc(now) }).ConfigureAwait(false);
|
||||
return SupportCloseOutcome.Requested;
|
||||
|
||||
static string? Cut(string? value, int max) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().Length <= max ? value.Trim() : value.Trim()[..max];
|
||||
}
|
||||
|
||||
/// <summary>Close requests the agent should send now: cases with a number whose closing is not confirmed.</summary>
|
||||
public async Task<IReadOnlyList<SupportCaseRow>> GetDueSupportClosesAsync(DateTimeOffset now, int max = 10, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<SupportCaseRow>("""
|
||||
SELECT TOP (@max) * FROM insight.SupportCases
|
||||
WHERE Status = 'CloseRequested' AND Reference IS NOT NULL AND (CloseNextAttemptAt IS NULL OR CloseNextAttemptAt <= @now)
|
||||
ORDER BY CloseRequestedAt;
|
||||
""", new { max, now = Utc(now) }).ConfigureAwait(false);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
/// <summary>The hub confirmed the closing.</summary>
|
||||
public async Task MarkSupportClosedAsync(long caseId, DateTime? closedAt, string? closedBy, DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync("""
|
||||
UPDATE insight.SupportCases
|
||||
SET Status = 'Closed', ClosedAt = COALESCE(@closedAt, @now), ClosedBy = @closedBy, CloseAttempts = CloseAttempts + 1, LastError = NULL, UpdatedAt = @now
|
||||
WHERE CaseId = @caseId;
|
||||
""", new { caseId, closedAt, closedBy, now = Utc(now) }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A failed attempt to close. With <paramref name="nextAttemptAt"/> null the hub refused for good: the case is open
|
||||
/// again with the reason in LastError.
|
||||
/// </summary>
|
||||
public async Task MarkSupportCloseFailedAsync(long caseId, string error, DateTimeOffset now, DateTimeOffset? nextAttemptAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync("""
|
||||
UPDATE insight.SupportCases
|
||||
SET CloseAttempts = CloseAttempts + 1, LastError = LEFT(@error, 1000), UpdatedAt = @now, CloseNextAttemptAt = @next,
|
||||
Status = CASE WHEN @next IS NULL THEN 'Open' ELSE Status END,
|
||||
CloseRequestedAt = CASE WHEN @next IS NULL THEN NULL ELSE CloseRequestedAt END
|
||||
WHERE CaseId = @caseId;
|
||||
""", new { caseId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
16
src/SQLInsight.Repository/Migrations/0012_support_close.sql
Normal file
16
src/SQLInsight.Repository/Migrations/0012_support_close.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/* Closing support cases from SQLInsight Web (the /support page or POST /api/v1/support-cases/{id}/close): the web
|
||||
UI records the request, the agent sends it to SQLInsight Hub and sets Closed when the hub has confirmed it. A request
|
||||
on a case without a number yet is sent once the hub has created the case. */
|
||||
ALTER TABLE insight.SupportCases ADD
|
||||
CloseRequestedAt datetime2(0) NULL,
|
||||
CloseRequestedBy nvarchar(256) NULL,
|
||||
CloseComment nvarchar(1000) NULL,
|
||||
CloseAttempts int NOT NULL CONSTRAINT DF_SupportCases_CloseAttempts DEFAULT (0),
|
||||
CloseNextAttemptAt datetime2(0) NULL,
|
||||
ClosedBy nvarchar(300) NULL;
|
||||
GO
|
||||
ALTER TABLE insight.SupportCases DROP CONSTRAINT CK_SupportCases_Status;
|
||||
ALTER TABLE insight.SupportCases ADD CONSTRAINT CK_SupportCases_Status CHECK (Status IN ('Pending', 'Open', 'CloseRequested', 'Closed', 'Failed'));
|
||||
GO
|
||||
/* Like INSERT in 0005: the web UI may record a close request on these columns only. */
|
||||
GRANT UPDATE ON insight.SupportCases (CloseRequestedAt, CloseRequestedBy, CloseComment, CloseNextAttemptAt, Status, UpdatedAt) TO public;
|
||||
137
src/SQLInsight.Web/Api/SupportApiEndpoints.cs
Normal file
137
src/SQLInsight.Web/Api/SupportApiEndpoints.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using SQLInsight.Repository;
|
||||
|
||||
namespace SQLInsight.Web.Api;
|
||||
|
||||
/// <summary>Body of POST /api/v1/support-cases.</summary>
|
||||
public sealed record SupportCaseCreateBody(string? Title, string? Description, string? ContactName, string? ContactEmail, string? ContactPhone, long? FindingId);
|
||||
|
||||
/// <summary>Body of POST /api/v1/support-cases/{id}/close.</summary>
|
||||
public sealed record SupportCaseCloseBody(string? Comment);
|
||||
|
||||
/// <summary>
|
||||
/// Support cases through the API, e.g. from an ITSM system: create, list, read and close. The same logic as the
|
||||
/// /support page; the agent sends them to SQLInsight Hub. Writing needs the scope support:write for tokens.
|
||||
/// </summary>
|
||||
public static class SupportApiEndpoints
|
||||
{
|
||||
public static void Map(IEndpointRouteBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
var api = app.MapGroup("/api/v1/support-cases").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
|
||||
api.MapPost("", CreateAsync)
|
||||
.WithSummary("Opret en supportsag / Create a support case")
|
||||
.WithDescription("202 Accepted with the local id and status Pending; the case number (SUP-…) follows when the agent has heard from SQLInsight Hub. "
|
||||
+ "Idempotency-Key header: the same key returns the same case. Needs scope support:write for tokens.");
|
||||
api.MapGet("", ListAsync).WithSummary("Kundens supportsager / Support cases (status, from, to, limit, continuation)");
|
||||
api.MapGet("/{id:long}", GetAsync).WithSummary("Én supportsag med status og sagsnummer / One support case");
|
||||
api.MapPost("/{id:long}/close", CloseAsync)
|
||||
.WithSummary("Luk en supportsag / Close a support case")
|
||||
.WithDescription("Status CloseRequested until the hub has confirmed it, then Closed. Closing a closed case returns 200. Needs scope support:write for tokens.");
|
||||
}
|
||||
|
||||
private static IResult Forbidden(string detail) =>
|
||||
Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt", detail: detail);
|
||||
|
||||
private static async Task<IResult> CreateAsync(SupportCaseCreateBody body, HttpContext http, InsightRepository repository, CancellationToken ct)
|
||||
{
|
||||
if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite))
|
||||
{
|
||||
return Forbidden("API-tokenet mangler scope \"support:write\".");
|
||||
}
|
||||
|
||||
var key = http.Request.Headers["Idempotency-Key"].ToString();
|
||||
var clientRequestId = key.Length == 0 ? Guid.NewGuid() : RequestId(key);
|
||||
var result = await Support.FileAsync(repository, clientRequestId, body.Title, body.Description, body.ContactName, body.ContactEmail, body.ContactPhone,
|
||||
body.FindingId, http.User.Identity?.Name, ct).ConfigureAwait(false);
|
||||
if (result.NotAllowed)
|
||||
{
|
||||
return Forbidden(Support.NotAllowedText);
|
||||
}
|
||||
|
||||
if (result.Error is { } error)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig sag", detail: error);
|
||||
}
|
||||
|
||||
var row = (await repository.GetSupportCaseAsync(result.CaseId!.Value, ct).ConfigureAwait(false))!;
|
||||
return Results.Accepted($"/api/v1/support-cases/{row.CaseId}", Json(row));
|
||||
}
|
||||
|
||||
/// <summary>An Idempotency-Key as the request id: a GUID as it is, anything else hashed to one.</summary>
|
||||
public static Guid RequestId(string key)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
return Guid.TryParse(key, out var guid) ? guid : new Guid(SHA256.HashData(Encoding.UTF8.GetBytes(key)).AsSpan(0, 16));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(InsightRepository repository, HttpContext http, CancellationToken ct,
|
||||
string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, int? limit = null, string? continuation = null)
|
||||
{
|
||||
var offset = 0;
|
||||
if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset))
|
||||
{
|
||||
return Results.BadRequest("Ugyldig continuation.");
|
||||
}
|
||||
|
||||
var size = Math.Clamp(limit ?? 100, 1, 1000);
|
||||
var (cases, more) = await repository.SearchSupportCasesAsync(status, from, to, offset, size, ct).ConfigureAwait(false);
|
||||
if (more)
|
||||
{
|
||||
http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return Results.Ok(cases.Select(Json));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(long id, InsightRepository repository, CancellationToken ct) =>
|
||||
await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound();
|
||||
|
||||
private static async Task<IResult> CloseAsync(long id, HttpContext http, InsightRepository repository, CancellationToken ct, SupportCaseCloseBody? body = null)
|
||||
{
|
||||
if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite))
|
||||
{
|
||||
return Forbidden("API-tokenet mangler scope \"support:write\".");
|
||||
}
|
||||
|
||||
if (body?.Comment?.Length > Contracts.SupportCaseCloseRequest.MaxComment)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig lukning", detail: $"Kommentaren må højst være {Contracts.SupportCaseCloseRequest.MaxComment} tegn.");
|
||||
}
|
||||
|
||||
var outcome = await repository.RequestSupportCloseAsync(id, http.User.Identity?.Name, body?.Comment, DateTimeOffset.UtcNow, ct).ConfigureAwait(false);
|
||||
if (outcome == SupportCloseOutcome.NotFound)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (outcome == SupportCloseOutcome.Failed)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Sagen kan ikke lukkes", detail: "SQLInsight Hub afviste sagen, så den findes ikke hos SEOSoft.");
|
||||
}
|
||||
|
||||
var row = (await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false))!;
|
||||
return outcome == SupportCloseOutcome.AlreadyClosed ? Results.Ok(Json(row)) : Results.Accepted($"/api/v1/support-cases/{id}", Json(row));
|
||||
}
|
||||
|
||||
private static object Json(SupportCaseRow c) => new
|
||||
{
|
||||
Id = c.CaseId,
|
||||
c.Reference,
|
||||
c.Title,
|
||||
c.Status,
|
||||
c.Source,
|
||||
c.FindingId,
|
||||
c.InstanceName,
|
||||
c.RuleId,
|
||||
c.RequestedBy,
|
||||
c.CreatedAt,
|
||||
c.SentAt,
|
||||
c.CloseRequestedAt,
|
||||
c.ClosedAt,
|
||||
c.ClosedBy,
|
||||
Error = c.Status == SupportCaseStatus.Failed ? c.LastError : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -6,12 +6,16 @@
|
|||
<PageTitle>Support · SQLInsight Pro</PageTitle>
|
||||
|
||||
<h1>Support</h1>
|
||||
<p class="lead">Supportsager hos SEOSoft. Sagen sendes af agenten til SQLInsight Hub, som giver den et sagsnummer. Status opdateres her, når SEOSoft lukker sagen.</p>
|
||||
<p class="lead">Supportsager hos SEOSoft. Sagen sendes af agenten til SQLInsight Hub, som giver den et sagsnummer. Status opdateres her, når sagen lukkes, af SEOSoft eller jer selv.</p>
|
||||
|
||||
@if (Sendt == 1)
|
||||
{
|
||||
<p class="notice ok" role="status">Sagen er sendt. Den får et sagsnummer, så snart agenten har været i kontakt med SQLInsight Hub (normalt inden for et minut).</p>
|
||||
}
|
||||
@if (Lukket == 1)
|
||||
{
|
||||
<p class="notice ok" role="status">Lukningen er sendt. Sagen står som lukket, når SQLInsight Hub har bekræftet den (normalt inden for et minut).</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Fejl))
|
||||
{
|
||||
<p class="notice bad" role="alert">@Fejl</p>
|
||||
|
|
@ -58,7 +62,7 @@ else
|
|||
else
|
||||
{
|
||||
<table>
|
||||
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
|
||||
<thead><tr><th>Sag</th><th>Titel</th><th>Status</th><th>Oprettet</th><th>Lukket</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _cases)
|
||||
{
|
||||
|
|
@ -68,6 +72,20 @@ else
|
|||
<td>@Support.StatusText(c)@if (c.Status == SupportCaseStatus.Failed && c.LastError is { } error) { <div class="muted">@error</div> }</td>
|
||||
<td class="nowrap">@Display.Time(c.CreatedAt)</td>
|
||||
<td class="nowrap">@Display.Time(c.ClosedAt)</td>
|
||||
<td>
|
||||
@if (c.Status is SupportCaseStatus.Open or SupportCaseStatus.Pending && c.CloseRequestedAt is null)
|
||||
{
|
||||
<form method="post" action="/support/@c.CaseId/luk" class="inline-form">
|
||||
<AntiforgeryToken />
|
||||
<input type="text" name="comment" maxlength="1000" placeholder="Kommentar (valgfri)" aria-label="Kommentar til lukning" />
|
||||
<button type="submit" class="button">Luk sag</button>
|
||||
</form>
|
||||
}
|
||||
else if (c.ClosedBy is { } by)
|
||||
{
|
||||
<span class="muted">@by</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
|
@ -79,6 +97,7 @@ else
|
|||
[CascadingParameter] private HttpContext? Http { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "fund")] public long? Fund { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "sendt")] public int? Sendt { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "lukket")] public int? Lukket { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; }
|
||||
|
||||
private HubStatusRow? _hub;
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit
|
|||
|
||||
IntegrationEndpoints.Map(app);
|
||||
ApiV1Endpoints.Map(app);
|
||||
SupportApiEndpoints.Map(app);
|
||||
app.MapOpenApi("/api/v1/openapi.json").RequireRead();
|
||||
Support.Map(app);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ namespace SQLInsight.Web;
|
|||
/// </summary>
|
||||
public static class Support
|
||||
{
|
||||
public static void Map(IEndpointRouteBuilder app) => app.MapPost("/support", CreateAsync);
|
||||
public static void Map(IEndpointRouteBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
app.MapPost("/support", CreateAsync);
|
||||
app.MapPost("/support/{id:long}/luk", CloseAsync);
|
||||
}
|
||||
|
||||
/// <summary>Title and description for a case about <paramref name="finding"/>. Sensitive measurements are left out.</summary>
|
||||
public static (string Title, string Description) Prefill(StoredFinding finding)
|
||||
|
|
@ -54,35 +59,96 @@ public static class Support
|
|||
{
|
||||
SupportCaseStatus.Pending => row.Attempts > 0 ? "Venter på hubben (prøver igen)" : "Sendes",
|
||||
SupportCaseStatus.Open => "Åben",
|
||||
SupportCaseStatus.CloseRequested => "Lukning sendes",
|
||||
SupportCaseStatus.Closed => "Lukket",
|
||||
_ => "Afvist",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>What filing a request came to.</summary>
|
||||
/// <param name="Existing">The same request (Idempotency-Key) was filed before: this is that case.</param>
|
||||
public sealed record FileResult(long? CaseId, string? Error, bool NotAllowed = false, bool Existing = false);
|
||||
|
||||
public const string NotAllowedText = "Supportsager kræver Professional eller Enterprise og at deling med SQLInsight Hub er slået til.";
|
||||
|
||||
/// <summary>
|
||||
/// Files a support request, from the form or the API: the same checks, and the same non-sensitive summary of the
|
||||
/// finding it is about. The agent sends it to the hub.
|
||||
/// </summary>
|
||||
public static async Task<FileResult> FileAsync(InsightRepository repository, Guid clientRequestId, string? title, string? description,
|
||||
string? contactName, string? contactEmail, string? contactPhone, long? findingId, string? requestedBy, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
if ((await repository.GetHubStatusAsync(cancellationToken).ConfigureAwait(false))?.AllowsSupportCases != true)
|
||||
{
|
||||
return new FileResult(null, NotAllowedText, NotAllowed: true);
|
||||
}
|
||||
|
||||
if (await repository.GetSupportCaseByRequestIdAsync(clientRequestId, cancellationToken).ConfigureAwait(false) is { } earlier)
|
||||
{
|
||||
return new FileResult(earlier.CaseId, null, Existing: true);
|
||||
}
|
||||
|
||||
var finding = findingId is { } fid ? await repository.GetFindingAsync(fid, cancellationToken).ConfigureAwait(false) : null;
|
||||
if (findingId is not null && finding is null)
|
||||
{
|
||||
return new FileResult(null, $"Fundet {findingId} findes ikke.");
|
||||
}
|
||||
|
||||
if (finding is not null)
|
||||
{
|
||||
var (prefilledTitle, prefilledDescription) = Prefill(finding);
|
||||
title = string.IsNullOrWhiteSpace(title) ? prefilledTitle : title;
|
||||
description = string.IsNullOrWhiteSpace(description) ? prefilledDescription : description.Trim() + "\n\n---\n" + prefilledDescription;
|
||||
description = description.Length > SupportCaseRequest.MaxDescription ? description[..SupportCaseRequest.MaxDescription] : description;
|
||||
}
|
||||
|
||||
var request = new SupportCaseRequest(clientRequestId, title?.Trim() ?? string.Empty, Blank(description), contactName?.Trim() ?? string.Empty,
|
||||
contactEmail?.Trim() ?? string.Empty, Blank(contactPhone), requestedBy, finding?.InstanceName, finding?.RuleId, finding?.Subject);
|
||||
if (request.Validate() is { } invalid)
|
||||
{
|
||||
return new FileResult(null, invalid);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var id = await repository.AddSupportRequestAsync(new NewSupportRequest(request.ClientRequestId, request.Title, request.Description, request.ContactName,
|
||||
request.ContactEmail, request.ContactPhone, request.RequestedBy, finding?.FindingId, finding?.InstanceName, finding?.RuleId, finding?.Subject),
|
||||
DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
|
||||
return new FileResult(id, null);
|
||||
}
|
||||
catch (Microsoft.Data.SqlClient.SqlException ex) when (ex.Number is 2601 or 2627)
|
||||
{
|
||||
// The same Idempotency-Key at the same moment: the other request won.
|
||||
var other = await repository.GetSupportCaseByRequestIdAsync(clientRequestId, cancellationToken).ConfigureAwait(false);
|
||||
return new FileResult(other?.CaseId, other is null ? ex.Message : null, Existing: other is not null);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateAsync(
|
||||
HttpContext http, InsightRepository repository,
|
||||
[FromForm] string? title, [FromForm] string? description, [FromForm] string? contactName, [FromForm] string? contactEmail,
|
||||
[FromForm] string? contactPhone, [FromForm] long? findingId)
|
||||
{
|
||||
var back = findingId is { } id ? $"&fund={id}" : string.Empty;
|
||||
if ((await repository.GetHubStatusAsync(http.RequestAborted))?.AllowsSupportCases != true)
|
||||
var result = await FileAsync(repository, Guid.NewGuid(), title, description, contactName, contactEmail, contactPhone, findingId,
|
||||
http.User.Identity?.Name, http.RequestAborted);
|
||||
return result.Error is { } error
|
||||
? Results.Redirect("/support?fejl=" + Uri.EscapeDataString(error) + (result.NotAllowed ? string.Empty : back))
|
||||
: Results.Redirect("/support?sendt=1");
|
||||
}
|
||||
|
||||
/// <summary>The "Luk sag" button on /support.</summary>
|
||||
private static async Task<IResult> CloseAsync(long id, HttpContext http, InsightRepository repository, [FromForm] string? comment)
|
||||
{
|
||||
return Results.Redirect("/support?fejl=" + Uri.EscapeDataString("Supportsager kræver Professional eller Enterprise og at deling med SQLInsight Hub er slået til."));
|
||||
}
|
||||
|
||||
var finding = findingId is { } fid ? await repository.GetFindingAsync(fid, http.RequestAborted) : null;
|
||||
var request = new SupportCaseRequest(Guid.NewGuid(), title?.Trim() ?? string.Empty, Blank(description), contactName?.Trim() ?? string.Empty,
|
||||
contactEmail?.Trim() ?? string.Empty, Blank(contactPhone), http.User.Identity?.Name, finding?.InstanceName, finding?.RuleId, finding?.Subject);
|
||||
if (request.Validate() is { } invalid)
|
||||
var outcome = await repository.RequestSupportCloseAsync(id, http.User.Identity?.Name, comment, DateTimeOffset.UtcNow, http.RequestAborted);
|
||||
return outcome switch
|
||||
{
|
||||
return Results.Redirect("/support?fejl=" + Uri.EscapeDataString(invalid) + back);
|
||||
SupportCloseOutcome.NotFound => Results.NotFound(),
|
||||
SupportCloseOutcome.Failed => Results.Redirect("/support?fejl=" + Uri.EscapeDataString("Sagen blev afvist af SQLInsight Hub og kan ikke lukkes.")),
|
||||
_ => Results.Redirect("/support?lukket=1"),
|
||||
};
|
||||
}
|
||||
|
||||
await repository.AddSupportRequestAsync(new NewSupportRequest(request.ClientRequestId, request.Title, request.Description, request.ContactName,
|
||||
request.ContactEmail, request.ContactPhone, request.RequestedBy, finding?.FindingId, finding?.InstanceName, finding?.RuleId, finding?.Subject),
|
||||
DateTimeOffset.UtcNow, http.RequestAborted);
|
||||
return Results.Redirect("/support?sendt=1");
|
||||
}
|
||||
|
||||
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
public static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
|
|
|||
213
tests/SQLInsight.IntegrationTests/SupportApiTests.cs
Normal file
213
tests/SQLInsight.IntegrationTests/SupportApiTests.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SQLInsight.Agent.Hub;
|
||||
using SQLInsight.Core.Findings;
|
||||
using SQLInsight.Core.Instances;
|
||||
using SQLInsight.Hub.Data;
|
||||
using SQLInsight.Repository;
|
||||
using SQLInsight.Rules.Engine;
|
||||
using SQLInsight.Web.Api;
|
||||
|
||||
namespace SQLInsight.IntegrationTests;
|
||||
|
||||
/// <summary>Support cases through the API of SQLInsight Web: created and closed, through the agent to the hub and back.</summary>
|
||||
[Collection(SqlServerTestGroup.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
public class SupportApiTests(SqlServerFixture sql, HubFixture hub) : IClassFixture<HubFixture>, IAsyncLifetime
|
||||
{
|
||||
private const string WriteToken = "sqli_test-itsm-write";
|
||||
private const string ReadToken = "sqli_test-read-only";
|
||||
|
||||
private readonly string _database = "SQLInsightSupApi_" + Guid.NewGuid().ToString("N")[..8];
|
||||
private InsightRepository _repository = null!;
|
||||
private long _findingId;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
||||
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
|
||||
await _repository.MigrateAsync();
|
||||
var id = await _repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo
|
||||
{
|
||||
ServerName = "SQL01", Version = SqlServerVersion.Parse("15.0.4355.3"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
|
||||
}, "SqlClient", false, DateTimeOffset.UtcNow);
|
||||
await _repository.SaveRuleResultsAsync(id,
|
||||
[
|
||||
new RuleResult
|
||||
{
|
||||
RuleId = "SQLI-CFG-001", RuleTitle = "max server memory", Outcome = RuleOutcome.Findings,
|
||||
Findings =
|
||||
[
|
||||
new Finding
|
||||
{
|
||||
RuleId = "SQLI-CFG-001", Severity = Severity.High, Title = "max server memory er ikke sat", Explanation = "SQL Server må bruge al hukommelse.",
|
||||
Recommendation = "Sæt den.", Evidence = [new Evidence("max server memory (MB)", "2147483647"), new Evidence("login", "KUNDE\\hemmelig", Sensitive: true)],
|
||||
},
|
||||
],
|
||||
},
|
||||
], DateTimeOffset.UtcNow);
|
||||
_findingId = (await _repository.GetActiveFindingsAsync(id)).Single().FindingId;
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
||||
|
||||
private Task AllowSupportAsync() => _repository.SaveHubStatusAsync(new HubStatusRow
|
||||
{
|
||||
Enabled = true, HubUrl = "http://localhost/", LicenseState = "Valid", LicenseMessage = "Professional", Customer = "Kunde A/S",
|
||||
LicensePlan = "Professional", LicenseExpires = DateTime.UtcNow.AddYears(1), UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
private sealed class TestServerClients(Func<HttpClient> create) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => create();
|
||||
}
|
||||
|
||||
private HubSyncService Sync(string license)
|
||||
{
|
||||
var options = Options.Create(new SQLInsight.Agent.Hub.HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = license });
|
||||
options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey;
|
||||
return new HubSyncService(options, _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger<HubSyncService>.Instance);
|
||||
}
|
||||
|
||||
private WebApplicationFactory<Program> Web() => new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
{
|
||||
b.UseEnvironment("Development");
|
||||
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
|
||||
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
||||
});
|
||||
|
||||
private static StringContent Body(object value) => new(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
[Fact]
|
||||
public async Task Case_created_and_closed_through_the_api_reaches_the_hub_and_its_report()
|
||||
{
|
||||
var customer = $"Support API {Guid.NewGuid():N}"[..28];
|
||||
var license = hub.License(customer);
|
||||
await AllowSupportAsync();
|
||||
await using var web = Web();
|
||||
var client = web.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/support-cases")
|
||||
{
|
||||
Content = Body(new { contactName = "Anna", contactEmail = "anna@kunde.dk", findingId = _findingId, description = "Siden mandag." }),
|
||||
};
|
||||
request.Headers.Add("Idempotency-Key", "itsm-INC0012345");
|
||||
var created = await client.SendAsync(request);
|
||||
var again = new HttpRequestMessage(HttpMethod.Post, "/api/v1/support-cases") { Content = Body(new { title = "Andet", contactName = "Anna", contactEmail = "anna@kunde.dk" }) };
|
||||
again.Headers.Add("Idempotency-Key", "itsm-INC0012345");
|
||||
var repeated = await client.SendAsync(again);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, created.StatusCode);
|
||||
var body = JsonDocument.Parse(await created.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = body.GetProperty("id").GetInt64();
|
||||
Assert.Equal("Pending", body.GetProperty("status").GetString());
|
||||
Assert.Equal($"/api/v1/support-cases/{id}", created.Headers.Location!.OriginalString);
|
||||
Assert.Equal(id, JsonDocument.Parse(await repeated.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetInt64());
|
||||
var stored = (await _repository.GetSupportCaseAsync(id))!;
|
||||
Assert.Equal("max server memory er ikke sat", stored.Title); // from the finding
|
||||
Assert.Contains("Siden mandag.", stored.Description, StringComparison.Ordinal);
|
||||
Assert.Contains("2147483647", stored.Description, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("hemmelig", stored.Description, StringComparison.Ordinal); // sensitive evidence left out
|
||||
|
||||
await Sync(license).RunOnceAsync(CancellationToken.None);
|
||||
var numbered = JsonDocument.Parse(await client.GetStringAsync($"/api/v1/support-cases/{id}")).RootElement;
|
||||
Assert.StartsWith("SUP-", numbered.GetProperty("reference").GetString(), StringComparison.Ordinal);
|
||||
Assert.Equal("Open", numbered.GetProperty("status").GetString());
|
||||
|
||||
var close = await client.PostAsync($"/api/v1/support-cases/{id}/close", Body(new { comment = "Løst ved at sætte max server memory." }));
|
||||
Assert.Equal(HttpStatusCode.Accepted, close.StatusCode);
|
||||
Assert.Equal("CloseRequested", JsonDocument.Parse(await close.Content.ReadAsStringAsync()).RootElement.GetProperty("status").GetString());
|
||||
|
||||
await Sync(license).RunOnceAsync(CancellationToken.None);
|
||||
|
||||
var closed = JsonDocument.Parse(await client.GetStringAsync($"/api/v1/support-cases/{id}")).RootElement;
|
||||
Assert.Equal("Closed", closed.GetProperty("status").GetString());
|
||||
Assert.StartsWith("Kunde:", closed.GetProperty("closedBy").GetString(), StringComparison.Ordinal);
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/support-cases/{id}/close", Body(new { }))).StatusCode); // idempotent
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync("/api/v1/support-cases/999999/close", Body(new { }))).StatusCode);
|
||||
var list = JsonDocument.Parse(await client.GetStringAsync("/api/v1/support-cases?status=Closed")).RootElement;
|
||||
Assert.Equal(1, list.GetArrayLength());
|
||||
|
||||
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
|
||||
var atHub = await db.SupportCases.SingleAsync(c => c.Customer!.Name == customer);
|
||||
Assert.False(atHub.IsOpen);
|
||||
Assert.Equal("Løst ved at sætte max server memory.", atHub.ClosedComment);
|
||||
var report = await new SQLInsight.Hub.Portal.PortalQueries(db, TimeProvider.System).SupportReportAsync(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), atHub.CustomerId);
|
||||
Assert.Equal("Kunde", Assert.Single(report.Cases).ClosedByWhom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hub_closes_only_the_license_customers_own_cases()
|
||||
{
|
||||
var owner = hub.License($"Ejer {Guid.NewGuid():N}"[..20]);
|
||||
var stranger = hub.License($"Fremmed {Guid.NewGuid():N}"[..20]);
|
||||
await _repository.AddSupportRequestAsync(new NewSupportRequest(Guid.NewGuid(), "Hjælp", null, "Anna", "anna@kunde.dk", null, null), DateTimeOffset.UtcNow);
|
||||
await Sync(owner).RunOnceAsync(CancellationToken.None);
|
||||
var reference = (await _repository.GetSupportCasesAsync()).Single().Reference!;
|
||||
|
||||
var client = hub.Factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("SQLInsight-License", stranger);
|
||||
var response = await client.PostAsync($"/api/v1/cases/{reference}/close", JsonContent.Create(new { comment = "nej" }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tokens_need_the_support_scope_and_a_support_license()
|
||||
{
|
||||
int port;
|
||||
using (var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0))
|
||||
{
|
||||
probe.Start();
|
||||
port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
||||
}
|
||||
|
||||
await using var web = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
{
|
||||
b.UseEnvironment("Development");
|
||||
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
|
||||
b.UseSetting("SQLInsight:Web:Authentication", "Windows");
|
||||
b.UseSetting("SQLInsight:Web:ApiTokens:itsm:hash", "sha256:" + ApiTokenHandler.Hash(WriteToken));
|
||||
b.UseSetting("SQLInsight:Web:ApiTokens:itsm:scopes:0", "read");
|
||||
b.UseSetting("SQLInsight:Web:ApiTokens:itsm:scopes:1", "support:write");
|
||||
b.UseSetting("SQLInsight:Web:ApiTokens:grafana", "sha256:" + ApiTokenHandler.Hash(ReadToken));
|
||||
b.UseSetting("urls", $"http://127.0.0.1:{port}");
|
||||
});
|
||||
web.UseKestrel();
|
||||
web.StartServer();
|
||||
HttpClient Client(string token)
|
||||
{
|
||||
var c = web.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri($"http://127.0.0.1:{port}") });
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return c;
|
||||
}
|
||||
|
||||
var case_ = new { title = "Fra ITSM", contactName = "Anna", contactEmail = "anna@kunde.dk" };
|
||||
var withoutLicense = await Client(WriteToken).PostAsync("/api/v1/support-cases", Body(case_));
|
||||
await AllowSupportAsync();
|
||||
var readOnly = await Client(ReadToken).PostAsync("/api/v1/support-cases", Body(case_));
|
||||
var allowed = await Client(WriteToken).PostAsync("/api/v1/support-cases", Body(case_));
|
||||
var id = JsonDocument.Parse(await allowed.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetInt64();
|
||||
var readOnlyClose = await Client(ReadToken).PostAsync($"/api/v1/support-cases/{id}/close", Body(new { }));
|
||||
var readOnlyList = await Client(ReadToken).GetAsync("/api/v1/support-cases");
|
||||
var writeOnlyPages = await Client(WriteToken).GetAsync("/support");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, withoutLicense.StatusCode);
|
||||
Assert.Equal("application/problem+json", withoutLicense.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, readOnly.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Accepted, allowed.StatusCode);
|
||||
Assert.Equal("token:itsm", (await _repository.GetSupportCaseAsync(id))!.RequestedBy);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, readOnlyClose.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, readOnlyList.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, writeOnlyPages.StatusCode); // a token never opens the web pages
|
||||
}
|
||||
}
|
||||
|
|
@ -290,7 +290,7 @@ public class ApiV1Tests(SqlServerFixture sql) : IAsyncLifetime
|
|||
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
|
||||
.Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/'))
|
||||
.Where(p => p.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase) && !p.EndsWith("openapi.json", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => System.Text.RegularExpressions.Regex.Replace(p, @"\{([^}:]+)(:[^}]+)?\}", "{$1}"))
|
||||
.Select(p => System.Text.RegularExpressions.Regex.Replace(p, @"\{([^}:]+)(:[^}]+)?\}", "{$1}").TrimEnd('/'))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
|
|
|
|||
|
|
@ -151,6 +151,12 @@ public partial class WebSupportTests(SqlServerFixture sql) : IAsyncLifetime
|
|||
|
||||
Assert.True(id > 0);
|
||||
await Assert.ThrowsAsync<SqlException>(() => readOnly.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
// Closing is only a request on the close columns: allowed with the same read-only login.
|
||||
await _repository.MarkSupportRequestSentAsync(id, "SUP-000001", true, null, DateTimeOffset.UtcNow);
|
||||
Assert.Equal(SupportCloseOutcome.Requested, await readOnly.RequestSupportCloseAsync(id, "KUNDE\\anna", "Løst", DateTimeOffset.UtcNow));
|
||||
Assert.Equal(SupportCaseStatus.CloseRequested, (await readOnly.GetSupportCaseAsync(id))!.Status);
|
||||
await Assert.ThrowsAsync<SqlException>(() => readOnly.MarkSupportClosedAsync(id, DateTime.UtcNow, "Kunde: x", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue