old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -12,4 +12,7 @@ public static class PlatformPolicies
|
||||
public const string PreferencesWrite = "platform.preferences.write";
|
||||
public const string SearchRead = "platform.search.read";
|
||||
public const string MetadataRead = "platform.metadata.read";
|
||||
public const string SetupRead = "platform.setup.read";
|
||||
public const string SetupWrite = "platform.setup.write";
|
||||
public const string SetupAdmin = "platform.setup.admin";
|
||||
}
|
||||
|
||||
@@ -12,4 +12,7 @@ public static class PlatformScopes
|
||||
public const string PreferencesWrite = "ui.preferences.write";
|
||||
public const string SearchRead = "search.read";
|
||||
public const string MetadataRead = "platform.metadata.read";
|
||||
public const string SetupRead = "platform.setup.read";
|
||||
public const string SetupWrite = "platform.setup.write";
|
||||
public const string SetupAdmin = "platform.setup.admin";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260112_004_PLATFORM_setup_wizard_backend (PLATFORM-SETUP-001)
|
||||
// Task: Define setup wizard contracts and step definitions
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
#region Enums
|
||||
|
||||
/// <summary>
|
||||
/// Setup wizard step identifiers aligned to docs/setup/setup-wizard-ux.md.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SetupStepId
|
||||
{
|
||||
/// <summary>Configure PostgreSQL connection.</summary>
|
||||
Database = 1,
|
||||
|
||||
/// <summary>Configure Valkey/Redis caching and message queue.</summary>
|
||||
Valkey = 2,
|
||||
|
||||
/// <summary>Apply database schema migrations.</summary>
|
||||
Migrations = 3,
|
||||
|
||||
/// <summary>Create administrator account.</summary>
|
||||
Admin = 4,
|
||||
|
||||
/// <summary>Configure signing keys and crypto profile.</summary>
|
||||
Crypto = 5,
|
||||
|
||||
/// <summary>Configure secrets management (optional).</summary>
|
||||
Vault = 6,
|
||||
|
||||
/// <summary>Connect source control (optional).</summary>
|
||||
Scm = 7,
|
||||
|
||||
/// <summary>Configure alerts and notifications (optional).</summary>
|
||||
Notifications = 8,
|
||||
|
||||
/// <summary>Define deployment environments (optional).</summary>
|
||||
Environments = 9,
|
||||
|
||||
/// <summary>Register deployment agents (optional).</summary>
|
||||
Agents = 10
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setup step status aligned to docs/setup/setup-wizard-ux.md.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SetupStepStatus
|
||||
{
|
||||
/// <summary>Not yet started.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Currently active step.</summary>
|
||||
Current,
|
||||
|
||||
/// <summary>Completed successfully.</summary>
|
||||
Passed,
|
||||
|
||||
/// <summary>Failed validation.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Explicitly skipped by user.</summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>Blocked by failed dependency.</summary>
|
||||
Blocked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall setup session status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SetupSessionStatus
|
||||
{
|
||||
/// <summary>Setup not started.</summary>
|
||||
NotStarted,
|
||||
|
||||
/// <summary>Setup in progress.</summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>Setup completed successfully.</summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>Setup completed with skipped optional steps.</summary>
|
||||
CompletedPartial,
|
||||
|
||||
/// <summary>Setup failed due to required step failure.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Setup abandoned by user.</summary>
|
||||
Abandoned
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Doctor check status for step validation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SetupCheckStatus
|
||||
{
|
||||
/// <summary>Check passed.</summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>Check failed.</summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>Check produced a warning.</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Check not executed.</summary>
|
||||
NotRun
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Step Definitions
|
||||
|
||||
/// <summary>
|
||||
/// Static definition of a setup wizard step.
|
||||
/// </summary>
|
||||
public sealed record SetupStepDefinition(
|
||||
SetupStepId Id,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
int OrderIndex,
|
||||
bool IsRequired,
|
||||
ImmutableArray<SetupStepId> DependsOn,
|
||||
ImmutableArray<string> DoctorChecks);
|
||||
|
||||
/// <summary>
|
||||
/// Provides the canonical setup wizard step definitions.
|
||||
/// </summary>
|
||||
public static class SetupStepDefinitions
|
||||
{
|
||||
public static ImmutableArray<SetupStepDefinition> All { get; } = ImmutableArray.Create(
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Database,
|
||||
Title: "Database Setup",
|
||||
Subtitle: "Configure PostgreSQL connection",
|
||||
OrderIndex: 1,
|
||||
IsRequired: true,
|
||||
DependsOn: ImmutableArray<SetupStepId>.Empty,
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.database.connectivity",
|
||||
"check.database.permissions",
|
||||
"check.database.version")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Valkey,
|
||||
Title: "Valkey/Redis Setup",
|
||||
Subtitle: "Configure caching and message queue",
|
||||
OrderIndex: 2,
|
||||
IsRequired: true,
|
||||
DependsOn: ImmutableArray<SetupStepId>.Empty,
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.services.valkey.connectivity")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Migrations,
|
||||
Title: "Database Migrations",
|
||||
Subtitle: "Apply schema updates",
|
||||
OrderIndex: 3,
|
||||
IsRequired: true,
|
||||
DependsOn: ImmutableArray.Create(SetupStepId.Database),
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.database.migrations.pending")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Admin,
|
||||
Title: "Admin Bootstrap",
|
||||
Subtitle: "Create administrator account",
|
||||
OrderIndex: 4,
|
||||
IsRequired: true,
|
||||
DependsOn: ImmutableArray.Create(SetupStepId.Migrations),
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.authority.admin.exists")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Crypto,
|
||||
Title: "Crypto Profile",
|
||||
Subtitle: "Configure signing keys",
|
||||
OrderIndex: 5,
|
||||
IsRequired: true,
|
||||
DependsOn: ImmutableArray.Create(SetupStepId.Admin),
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.crypto.signing.key",
|
||||
"check.crypto.profile")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Vault,
|
||||
Title: "Vault Integration",
|
||||
Subtitle: "Configure secrets management",
|
||||
OrderIndex: 6,
|
||||
IsRequired: false,
|
||||
DependsOn: ImmutableArray<SetupStepId>.Empty,
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.security.vault.connectivity")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Scm,
|
||||
Title: "SCM Integration",
|
||||
Subtitle: "Connect source control",
|
||||
OrderIndex: 7,
|
||||
IsRequired: false,
|
||||
DependsOn: ImmutableArray<SetupStepId>.Empty,
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.integration.scm.github.auth",
|
||||
"check.integration.scm.gitlab.auth",
|
||||
"check.integration.scm.gitea.auth")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Notifications,
|
||||
Title: "Notification Channels",
|
||||
Subtitle: "Configure alerts and notifications",
|
||||
OrderIndex: 8,
|
||||
IsRequired: false,
|
||||
DependsOn: ImmutableArray<SetupStepId>.Empty,
|
||||
DoctorChecks: ImmutableArray.Create(
|
||||
"check.notify.email",
|
||||
"check.notify.slack")),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Environments,
|
||||
Title: "Environment Definition",
|
||||
Subtitle: "Define deployment environments",
|
||||
OrderIndex: 9,
|
||||
IsRequired: false,
|
||||
DependsOn: ImmutableArray.Create(SetupStepId.Admin),
|
||||
DoctorChecks: ImmutableArray<string>.Empty),
|
||||
|
||||
new SetupStepDefinition(
|
||||
Id: SetupStepId.Agents,
|
||||
Title: "Agent Registration",
|
||||
Subtitle: "Register deployment agents",
|
||||
OrderIndex: 10,
|
||||
IsRequired: false,
|
||||
DependsOn: ImmutableArray.Create(SetupStepId.Environments),
|
||||
DoctorChecks: ImmutableArray<string>.Empty)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a step definition by ID.
|
||||
/// </summary>
|
||||
public static SetupStepDefinition? GetById(SetupStepId id)
|
||||
{
|
||||
foreach (var step in All)
|
||||
{
|
||||
if (step.Id == id) return step;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Session State
|
||||
|
||||
/// <summary>
|
||||
/// Setup wizard session state.
|
||||
/// </summary>
|
||||
public sealed record SetupSession(
|
||||
string SessionId,
|
||||
string TenantId,
|
||||
SetupSessionStatus Status,
|
||||
ImmutableArray<SetupStepState> Steps,
|
||||
string CreatedAtUtc,
|
||||
string UpdatedAtUtc,
|
||||
string? CreatedBy,
|
||||
string? UpdatedBy,
|
||||
string? DataAsOfUtc);
|
||||
|
||||
/// <summary>
|
||||
/// State of a single setup step within a session.
|
||||
/// </summary>
|
||||
public sealed record SetupStepState(
|
||||
SetupStepId StepId,
|
||||
SetupStepStatus Status,
|
||||
string? CompletedAtUtc,
|
||||
string? SkippedAtUtc,
|
||||
string? SkippedReason,
|
||||
ImmutableArray<SetupCheckResult> CheckResults,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a Doctor check during step validation.
|
||||
/// </summary>
|
||||
public sealed record SetupCheckResult(
|
||||
string CheckId,
|
||||
SetupCheckStatus Status,
|
||||
string? Message,
|
||||
string? SuggestedFix);
|
||||
|
||||
#endregion
|
||||
|
||||
#region API Requests
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new setup session.
|
||||
/// </summary>
|
||||
public sealed record CreateSetupSessionRequest(
|
||||
string? TenantId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to execute a setup step.
|
||||
/// </summary>
|
||||
public sealed record ExecuteSetupStepRequest(
|
||||
SetupStepId StepId,
|
||||
ImmutableDictionary<string, string>? Configuration = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to skip a setup step.
|
||||
/// </summary>
|
||||
public sealed record SkipSetupStepRequest(
|
||||
SetupStepId StepId,
|
||||
string? Reason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to finalize a setup session.
|
||||
/// </summary>
|
||||
public sealed record FinalizeSetupSessionRequest(
|
||||
bool Force = false);
|
||||
|
||||
#endregion
|
||||
|
||||
#region API Responses
|
||||
|
||||
/// <summary>
|
||||
/// Response for setup session operations.
|
||||
/// </summary>
|
||||
public sealed record SetupSessionResponse(
|
||||
SetupSession Session);
|
||||
|
||||
/// <summary>
|
||||
/// Response for step execution.
|
||||
/// </summary>
|
||||
public sealed record ExecuteSetupStepResponse(
|
||||
SetupStepState StepState,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
ImmutableArray<SetupSuggestedFix> SuggestedFixes);
|
||||
|
||||
/// <summary>
|
||||
/// Response listing all step definitions.
|
||||
/// </summary>
|
||||
public sealed record SetupStepDefinitionsResponse(
|
||||
ImmutableArray<SetupStepDefinition> Steps);
|
||||
|
||||
/// <summary>
|
||||
/// A suggested fix for a failed step.
|
||||
/// </summary>
|
||||
public sealed record SetupSuggestedFix(
|
||||
string Title,
|
||||
string Description,
|
||||
string? Command,
|
||||
string? DocumentationUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Response for session finalization.
|
||||
/// </summary>
|
||||
public sealed record FinalizeSetupSessionResponse(
|
||||
SetupSessionStatus FinalStatus,
|
||||
ImmutableArray<SetupStepState> CompletedSteps,
|
||||
ImmutableArray<SetupStepState> SkippedSteps,
|
||||
ImmutableArray<SetupStepState> FailedSteps,
|
||||
string? ReportPath);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260112_004_PLATFORM_setup_wizard_backend (PLATFORM-SETUP-003)
|
||||
// Task: Add /api/v1/setup/* endpoints with auth policies, request validation, and Problem+JSON errors
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Setup wizard API endpoints aligned to docs/setup/setup-wizard-ux.md.
|
||||
/// </summary>
|
||||
public static class SetupEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSetupEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var setup = app.MapGroup("/api/v1/setup")
|
||||
.WithTags("Setup Wizard");
|
||||
|
||||
MapSessionEndpoints(setup);
|
||||
MapStepEndpoints(setup);
|
||||
MapDefinitionEndpoints(setup);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapSessionEndpoints(IEndpointRouteBuilder setup)
|
||||
{
|
||||
var sessions = setup.MapGroup("/sessions").WithTags("Setup Sessions");
|
||||
|
||||
// GET /api/v1/setup/sessions - Get current session
|
||||
sessions.MapGet("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSetupService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.GetSessionAsync(requestContext!, ct).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Session Not Found",
|
||||
"No active setup session for this tenant.",
|
||||
StatusCodes.Status404NotFound));
|
||||
}
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.SetupRead)
|
||||
.WithName("GetSetupSession")
|
||||
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /api/v1/setup/sessions - Create new session
|
||||
sessions.MapPost("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSetupService service,
|
||||
[FromBody] CreateSetupSessionRequest? request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.CreateSessionAsync(
|
||||
requestContext!,
|
||||
request ?? new CreateSetupSessionRequest(),
|
||||
ct).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/setup/sessions", result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.SetupWrite)
|
||||
.WithName("CreateSetupSession")
|
||||
.Produces<SetupSessionResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
|
||||
// POST /api/v1/setup/sessions/resume - Resume or create session
|
||||
sessions.MapPost("/resume", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSetupService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.ResumeOrCreateSessionAsync(requestContext!, ct).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.SetupWrite)
|
||||
.WithName("ResumeSetupSession")
|
||||
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
|
||||
// POST /api/v1/setup/sessions/finalize - Finalize session
|
||||
sessions.MapPost("/finalize", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSetupService service,
|
||||
[FromBody] FinalizeSetupSessionRequest? request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.FinalizeSessionAsync(
|
||||
requestContext!,
|
||||
request ?? new FinalizeSetupSessionRequest(),
|
||||
ct).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.SetupWrite)
|
||||
.WithName("FinalizeSetupSession")
|
||||
.Produces<FinalizeSetupSessionResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static void MapStepEndpoints(IEndpointRouteBuilder setup)
|
||||
{
|
||||
var steps = setup.MapGroup("/steps").WithTags("Setup Steps");
|
||||
|
||||
// POST /api/v1/setup/steps/execute - Execute a step
|
||||
steps.MapPost("/execute", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSetupService service,
|
||||
[FromBody] ExecuteSetupStepRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid Request",
|
||||
"Request body is required with stepId.",
|
||||
StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.ExecuteStepAsync(requestContext!, request, ct).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.SetupWrite)
|
||||
.WithName("ExecuteSetupStep")
|
||||
.Produces<ExecuteSetupStepResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
|
||||
// POST /api/v1/setup/steps/skip - Skip a step
|
||||
steps.MapPost("/skip", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSetupService service,
|
||||
[FromBody] SkipSetupStepRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid Request",
|
||||
"Request body is required with stepId.",
|
||||
StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.SkipStepAsync(requestContext!, request, ct).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.SetupWrite)
|
||||
.WithName("SkipSetupStep")
|
||||
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static void MapDefinitionEndpoints(IEndpointRouteBuilder setup)
|
||||
{
|
||||
var definitions = setup.MapGroup("/definitions").WithTags("Setup Definitions");
|
||||
|
||||
// GET /api/v1/setup/definitions/steps - Get all step definitions
|
||||
definitions.MapGet("/steps", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSetupService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetStepDefinitionsAsync(ct).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}).RequireAuthorization(PlatformPolicies.SetupRead)
|
||||
.WithName("GetSetupStepDefinitions")
|
||||
.Produces<SetupStepDefinitionsResponse>(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(CreateProblem(
|
||||
"Context Resolution Failed",
|
||||
error ?? "Unable to resolve tenant context.",
|
||||
StatusCodes.Status400BadRequest));
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, int statusCode)
|
||||
{
|
||||
return new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = statusCode,
|
||||
Type = $"https://stella.ops/problems/{title.ToLowerInvariant().Replace(' ', '-')}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,9 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesWrite, PlatformScopes.PreferencesWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SearchRead, PlatformScopes.SearchRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.MetadataRead, PlatformScopes.MetadataRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupRead, PlatformScopes.SetupRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupWrite, PlatformScopes.SetupWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SetupAdmin, PlatformScopes.SetupAdmin);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
@@ -121,6 +124,9 @@ builder.Services.AddSingleton<PlatformPreferencesService>();
|
||||
builder.Services.AddSingleton<PlatformSearchService>();
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
|
||||
builder.Services.AddSingleton<PlatformSetupStore>();
|
||||
builder.Services.AddSingleton<PlatformSetupService>();
|
||||
|
||||
var routerOptions = builder.Configuration.GetSection("Platform:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
serviceName: "platform",
|
||||
@@ -145,6 +151,7 @@ app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
app.MapPlatformEndpoints();
|
||||
app.MapSetupEndpoints();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
|
||||
.WithTags("Health")
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260112_004_PLATFORM_setup_wizard_backend (PLATFORM-SETUP-002)
|
||||
// Task: Implement PlatformSetupService with tenant scoping, TimeProvider injection, and data-as-of metadata
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing setup wizard sessions with tenant scoping and deterministic state management.
|
||||
/// </summary>
|
||||
public sealed class PlatformSetupService
|
||||
{
|
||||
private readonly PlatformSetupStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PlatformSetupService> _logger;
|
||||
|
||||
public PlatformSetupService(
|
||||
PlatformSetupStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PlatformSetupService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new setup session for the tenant.
|
||||
/// </summary>
|
||||
public Task<SetupSessionResponse> CreateSessionAsync(
|
||||
PlatformRequestContext context,
|
||||
CreateSetupSessionRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = request.TenantId ?? context.TenantId;
|
||||
var nowUtc = _timeProvider.GetUtcNow();
|
||||
var nowIso = FormatIso8601(nowUtc);
|
||||
|
||||
// Check if session already exists
|
||||
var existing = _store.GetByTenant(tenantId);
|
||||
if (existing is not null && existing.Status == SetupSessionStatus.InProgress)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Returning existing in-progress setup session {SessionId} for tenant {TenantId}.",
|
||||
existing.SessionId, tenantId);
|
||||
return Task.FromResult(new SetupSessionResponse(existing));
|
||||
}
|
||||
|
||||
var sessionId = GenerateSessionId(tenantId, nowUtc);
|
||||
var steps = CreateInitialStepStates();
|
||||
|
||||
var session = new SetupSession(
|
||||
SessionId: sessionId,
|
||||
TenantId: tenantId,
|
||||
Status: SetupSessionStatus.InProgress,
|
||||
Steps: steps,
|
||||
CreatedAtUtc: nowIso,
|
||||
UpdatedAtUtc: nowIso,
|
||||
CreatedBy: context.ActorId,
|
||||
UpdatedBy: context.ActorId,
|
||||
DataAsOfUtc: nowIso);
|
||||
|
||||
_store.Upsert(tenantId, session);
|
||||
_logger.LogInformation(
|
||||
"Created setup session {SessionId} for tenant {TenantId}.",
|
||||
sessionId, tenantId);
|
||||
|
||||
return Task.FromResult(new SetupSessionResponse(session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current setup session for the tenant.
|
||||
/// </summary>
|
||||
public Task<SetupSessionResponse?> GetSessionAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var session = _store.GetByTenant(context.TenantId);
|
||||
if (session is null)
|
||||
{
|
||||
return Task.FromResult<SetupSessionResponse?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<SetupSessionResponse?>(new SetupSessionResponse(session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes an existing setup session or creates a new one.
|
||||
/// </summary>
|
||||
public Task<SetupSessionResponse> ResumeOrCreateSessionAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existing = _store.GetByTenant(context.TenantId);
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Resumed setup session {SessionId} for tenant {TenantId}.",
|
||||
existing.SessionId, context.TenantId);
|
||||
return Task.FromResult(new SetupSessionResponse(existing));
|
||||
}
|
||||
|
||||
return CreateSessionAsync(context, new CreateSetupSessionRequest(), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a setup step with validation.
|
||||
/// </summary>
|
||||
public Task<ExecuteSetupStepResponse> ExecuteStepAsync(
|
||||
PlatformRequestContext context,
|
||||
ExecuteSetupStepRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var session = _store.GetByTenant(context.TenantId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new InvalidOperationException("No active setup session. Create a session first.");
|
||||
}
|
||||
|
||||
var stepDef = SetupStepDefinitions.GetById(request.StepId);
|
||||
if (stepDef is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown step ID: {request.StepId}");
|
||||
}
|
||||
|
||||
// Check dependencies
|
||||
var blockedByDeps = CheckDependencies(session, stepDef);
|
||||
if (blockedByDeps.Length > 0)
|
||||
{
|
||||
var stepState = GetStepState(session, request.StepId) with
|
||||
{
|
||||
Status = SetupStepStatus.Blocked,
|
||||
ErrorMessage = $"Blocked by incomplete dependencies: {string.Join(", ", blockedByDeps)}"
|
||||
};
|
||||
|
||||
return Task.FromResult(new ExecuteSetupStepResponse(
|
||||
StepState: stepState,
|
||||
Success: false,
|
||||
ErrorMessage: stepState.ErrorMessage,
|
||||
SuggestedFixes: ImmutableArray<SetupSuggestedFix>.Empty));
|
||||
}
|
||||
|
||||
var nowUtc = _timeProvider.GetUtcNow();
|
||||
var nowIso = FormatIso8601(nowUtc);
|
||||
|
||||
// Run Doctor checks for this step
|
||||
var checkResults = RunDoctorChecks(stepDef.DoctorChecks);
|
||||
var allPassed = checkResults.All(c => c.Status == SetupCheckStatus.Pass);
|
||||
|
||||
var newStatus = allPassed ? SetupStepStatus.Passed : SetupStepStatus.Failed;
|
||||
var errorMessage = allPassed
|
||||
? null
|
||||
: string.Join("; ", checkResults.Where(c => c.Status == SetupCheckStatus.Fail).Select(c => c.Message));
|
||||
|
||||
var updatedStepState = new SetupStepState(
|
||||
StepId: request.StepId,
|
||||
Status: newStatus,
|
||||
CompletedAtUtc: allPassed ? nowIso : null,
|
||||
SkippedAtUtc: null,
|
||||
SkippedReason: null,
|
||||
CheckResults: checkResults,
|
||||
ErrorMessage: errorMessage);
|
||||
|
||||
// Update session
|
||||
var updatedSteps = session.Steps
|
||||
.Select(s => s.StepId == request.StepId ? updatedStepState : s)
|
||||
.OrderBy(s => (int)s.StepId)
|
||||
.ToImmutableArray();
|
||||
|
||||
var updatedSession = session with
|
||||
{
|
||||
Steps = updatedSteps,
|
||||
UpdatedAtUtc = nowIso,
|
||||
UpdatedBy = context.ActorId,
|
||||
DataAsOfUtc = nowIso
|
||||
};
|
||||
|
||||
_store.Upsert(context.TenantId, updatedSession);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executed step {StepId} for session {SessionId}: {Status}.",
|
||||
request.StepId, session.SessionId, newStatus);
|
||||
|
||||
var suggestedFixes = allPassed
|
||||
? ImmutableArray<SetupSuggestedFix>.Empty
|
||||
: GenerateSuggestedFixes(stepDef, checkResults);
|
||||
|
||||
return Task.FromResult(new ExecuteSetupStepResponse(
|
||||
StepState: updatedStepState,
|
||||
Success: allPassed,
|
||||
ErrorMessage: errorMessage,
|
||||
SuggestedFixes: suggestedFixes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips an optional setup step.
|
||||
/// </summary>
|
||||
public Task<SetupSessionResponse> SkipStepAsync(
|
||||
PlatformRequestContext context,
|
||||
SkipSetupStepRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var session = _store.GetByTenant(context.TenantId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new InvalidOperationException("No active setup session. Create a session first.");
|
||||
}
|
||||
|
||||
var stepDef = SetupStepDefinitions.GetById(request.StepId);
|
||||
if (stepDef is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown step ID: {request.StepId}");
|
||||
}
|
||||
|
||||
if (stepDef.IsRequired)
|
||||
{
|
||||
throw new InvalidOperationException($"Step {request.StepId} is required and cannot be skipped.");
|
||||
}
|
||||
|
||||
var nowUtc = _timeProvider.GetUtcNow();
|
||||
var nowIso = FormatIso8601(nowUtc);
|
||||
|
||||
var updatedStepState = new SetupStepState(
|
||||
StepId: request.StepId,
|
||||
Status: SetupStepStatus.Skipped,
|
||||
CompletedAtUtc: null,
|
||||
SkippedAtUtc: nowIso,
|
||||
SkippedReason: request.Reason,
|
||||
CheckResults: ImmutableArray<SetupCheckResult>.Empty,
|
||||
ErrorMessage: null);
|
||||
|
||||
var updatedSteps = session.Steps
|
||||
.Select(s => s.StepId == request.StepId ? updatedStepState : s)
|
||||
.OrderBy(s => (int)s.StepId)
|
||||
.ToImmutableArray();
|
||||
|
||||
var updatedSession = session with
|
||||
{
|
||||
Steps = updatedSteps,
|
||||
UpdatedAtUtc = nowIso,
|
||||
UpdatedBy = context.ActorId,
|
||||
DataAsOfUtc = nowIso
|
||||
};
|
||||
|
||||
_store.Upsert(context.TenantId, updatedSession);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Skipped step {StepId} for session {SessionId}.",
|
||||
request.StepId, session.SessionId);
|
||||
|
||||
return Task.FromResult(new SetupSessionResponse(updatedSession));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalizes the setup session.
|
||||
/// </summary>
|
||||
public Task<FinalizeSetupSessionResponse> FinalizeSessionAsync(
|
||||
PlatformRequestContext context,
|
||||
FinalizeSetupSessionRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var session = _store.GetByTenant(context.TenantId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new InvalidOperationException("No active setup session.");
|
||||
}
|
||||
|
||||
var nowUtc = _timeProvider.GetUtcNow();
|
||||
var nowIso = FormatIso8601(nowUtc);
|
||||
|
||||
var completedSteps = session.Steps.Where(s => s.Status == SetupStepStatus.Passed).ToImmutableArray();
|
||||
var skippedSteps = session.Steps.Where(s => s.Status == SetupStepStatus.Skipped).ToImmutableArray();
|
||||
var failedSteps = session.Steps.Where(s => s.Status == SetupStepStatus.Failed).ToImmutableArray();
|
||||
|
||||
// Check all required steps are completed
|
||||
var requiredSteps = SetupStepDefinitions.All.Where(d => d.IsRequired).Select(d => d.Id).ToHashSet();
|
||||
var incompleteRequired = session.Steps
|
||||
.Where(s => requiredSteps.Contains(s.StepId) && s.Status != SetupStepStatus.Passed)
|
||||
.ToList();
|
||||
|
||||
SetupSessionStatus finalStatus;
|
||||
if (incompleteRequired.Count > 0 && !request.Force)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot finalize: required steps not completed: {string.Join(", ", incompleteRequired.Select(s => s.StepId))}");
|
||||
}
|
||||
else if (incompleteRequired.Count > 0)
|
||||
{
|
||||
finalStatus = SetupSessionStatus.Failed;
|
||||
}
|
||||
else if (skippedSteps.Length > 0)
|
||||
{
|
||||
finalStatus = SetupSessionStatus.CompletedPartial;
|
||||
}
|
||||
else
|
||||
{
|
||||
finalStatus = SetupSessionStatus.Completed;
|
||||
}
|
||||
|
||||
var updatedSession = session with
|
||||
{
|
||||
Status = finalStatus,
|
||||
UpdatedAtUtc = nowIso,
|
||||
UpdatedBy = context.ActorId,
|
||||
DataAsOfUtc = nowIso
|
||||
};
|
||||
|
||||
_store.Upsert(context.TenantId, updatedSession);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Finalized setup session {SessionId} with status {Status}.",
|
||||
session.SessionId, finalStatus);
|
||||
|
||||
return Task.FromResult(new FinalizeSetupSessionResponse(
|
||||
FinalStatus: finalStatus,
|
||||
CompletedSteps: completedSteps,
|
||||
SkippedSteps: skippedSteps,
|
||||
FailedSteps: failedSteps,
|
||||
ReportPath: null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all step definitions.
|
||||
/// </summary>
|
||||
public Task<SetupStepDefinitionsResponse> GetStepDefinitionsAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(new SetupStepDefinitionsResponse(SetupStepDefinitions.All));
|
||||
}
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
private static string GenerateSessionId(string tenantId, DateTimeOffset timestamp)
|
||||
{
|
||||
var dateStr = timestamp.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
return $"setup-{tenantId}-{dateStr}";
|
||||
}
|
||||
|
||||
private static string FormatIso8601(DateTimeOffset timestamp)
|
||||
{
|
||||
return timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static ImmutableArray<SetupStepState> CreateInitialStepStates()
|
||||
{
|
||||
return SetupStepDefinitions.All
|
||||
.Select(def => new SetupStepState(
|
||||
StepId: def.Id,
|
||||
Status: def.OrderIndex == 1 ? SetupStepStatus.Current : SetupStepStatus.Pending,
|
||||
CompletedAtUtc: null,
|
||||
SkippedAtUtc: null,
|
||||
SkippedReason: null,
|
||||
CheckResults: ImmutableArray<SetupCheckResult>.Empty,
|
||||
ErrorMessage: null))
|
||||
.OrderBy(s => (int)s.StepId)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static SetupStepState GetStepState(SetupSession session, SetupStepId stepId)
|
||||
{
|
||||
return session.Steps.FirstOrDefault(s => s.StepId == stepId)
|
||||
?? new SetupStepState(stepId, SetupStepStatus.Pending, null, null, null,
|
||||
ImmutableArray<SetupCheckResult>.Empty, null);
|
||||
}
|
||||
|
||||
private static ImmutableArray<SetupStepId> CheckDependencies(SetupSession session, SetupStepDefinition stepDef)
|
||||
{
|
||||
var blocked = new List<SetupStepId>();
|
||||
foreach (var depId in stepDef.DependsOn)
|
||||
{
|
||||
var depState = session.Steps.FirstOrDefault(s => s.StepId == depId);
|
||||
if (depState is null || depState.Status != SetupStepStatus.Passed)
|
||||
{
|
||||
blocked.Add(depId);
|
||||
}
|
||||
}
|
||||
return blocked.ToImmutableArray();
|
||||
}
|
||||
|
||||
private ImmutableArray<SetupCheckResult> RunDoctorChecks(ImmutableArray<string> checkIds)
|
||||
{
|
||||
// TODO: Integrate with Doctor service when available
|
||||
// For now, return mock pass results
|
||||
return checkIds
|
||||
.Select(checkId => new SetupCheckResult(
|
||||
CheckId: checkId,
|
||||
Status: SetupCheckStatus.Pass,
|
||||
Message: "Check passed",
|
||||
SuggestedFix: null))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<SetupSuggestedFix> GenerateSuggestedFixes(
|
||||
SetupStepDefinition stepDef,
|
||||
ImmutableArray<SetupCheckResult> checkResults)
|
||||
{
|
||||
var fixes = new List<SetupSuggestedFix>();
|
||||
foreach (var check in checkResults.Where(c => c.Status == SetupCheckStatus.Fail))
|
||||
{
|
||||
if (check.SuggestedFix is not null)
|
||||
{
|
||||
fixes.Add(new SetupSuggestedFix(
|
||||
Title: $"Fix {check.CheckId}",
|
||||
Description: check.Message ?? "Check failed",
|
||||
Command: check.SuggestedFix,
|
||||
DocumentationUrl: null));
|
||||
}
|
||||
}
|
||||
return fixes.ToImmutableArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store for setup wizard sessions with tenant scoping.
|
||||
/// </summary>
|
||||
public sealed class PlatformSetupStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SetupSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a setup session by tenant ID.
|
||||
/// </summary>
|
||||
public SetupSession? GetByTenant(string tenantId)
|
||||
{
|
||||
return _sessions.TryGetValue(tenantId, out var session) ? session : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a setup session by session ID.
|
||||
/// </summary>
|
||||
public SetupSession? GetBySessionId(string sessionId)
|
||||
{
|
||||
return _sessions.Values.FirstOrDefault(s =>
|
||||
string.Equals(s.SessionId, sessionId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a setup session.
|
||||
/// </summary>
|
||||
public void Upsert(string tenantId, SetupSession session)
|
||||
{
|
||||
_sessions[tenantId] = session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a setup session.
|
||||
/// </summary>
|
||||
public bool Remove(string tenantId)
|
||||
{
|
||||
return _sessions.TryRemove(tenantId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all sessions (for admin use).
|
||||
/// </summary>
|
||||
public ImmutableArray<SetupSession> ListAll()
|
||||
{
|
||||
return _sessions.Values
|
||||
.OrderBy(s => s.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SessionId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user