feat(m9/T32c): schema-library CRUD commands + handlers + Central UI page + read-accessor

This commit is contained in:
Joseph Doherty
2026-06-18 12:32:31 -04:00
parent 71d5722692
commit 71a2bca4df
13 changed files with 1363 additions and 0 deletions
@@ -31,6 +31,7 @@
<NavRailSection Title="Design" Key="design">
<NavRailItem Href="/design/templates" Text="Templates" />
<NavRailItem Href="/design/shared-scripts" Text="Shared Scripts" />
<NavRailItem Href="/design/schema-library" Text="Schema Library" />
<NavRailItem Href="/design/connections" Text="Connections" />
<NavRailItem Href="/design/external-systems" Text="External Systems" />
<NavRailItem Href="/design/transport/export" Text="Export Bundle" />
@@ -0,0 +1,228 @@
@page "/design/schema-library"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ISchemaLibraryService SchemaLibraryService
@inject IDialogService Dialog
@* Schema Library (M9-T32c): list + create/edit (via SchemaBuilder) + delete the
reusable named JSON-Schema library entries that the {"$ref":"lib:Name"} resolver
(T32b) resolves against. Every mutation is dispatched through ISchemaLibraryService
— the guard-running ManagementActor path — never a direct repo write. *@
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Schema Library</h4>
@if (!_editing)
{
<button class="btn btn-primary btn-sm" @onclick="BeginCreate">New Schema</button>
}
</div>
<ToastNotification @ref="_toast" />
@if (_editing)
{
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-3">@(_editId.HasValue ? $"Edit Schema: {_editOriginalName}" : "New Schema")</h5>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm"
placeholder="e.g. Address" @bind="_formName" />
<div class="form-text">Referenced as <code>lib:@(string.IsNullOrWhiteSpace(_formName) ? "Name" : _formName.Trim())</code>.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Scope <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control form-control-sm"
placeholder="(global)" @bind="_formScope" />
</div>
</div>
<label class="form-label small">Schema</label>
<SchemaBuilder Mode="value"
Value="@_formSchemaJson"
ValueChanged="@(v => _formSchemaJson = v)" />
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSchema" disabled="@_busy">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEdit" disabled="@_busy">Cancel</button>
</div>
</div>
</div>
}
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_schemas.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No library schemas defined.</p>
@if (!_editing)
{
<button class="btn btn-primary btn-sm" @onclick="BeginCreate">Create your first schema</button>
}
</div>
}
else
{
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Name</th>
<th>Scope</th>
<th>Reference</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var s in _schemas)
{
<tr @key="s.Id">
<td class="fw-semibold">@s.Name</td>
<td>@(string.IsNullOrWhiteSpace(s.Scope) ? "—" : s.Scope)</td>
<td><code>lib:@s.Name</code></td>
<td class="text-end">
<button class="btn btn-outline-primary btn-sm me-1"
@onclick="() => BeginEdit(s)">Edit</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteSchema(s)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
@code {
private List<SharedSchema> _schemas = new();
private bool _loading = true;
private bool _busy;
private bool _editing;
private int? _editId;
private string _editOriginalName = string.Empty;
private string _formName = string.Empty;
private string? _formScope;
private string? _formSchemaJson;
private string? _formError;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
try
{
_schemas = (await SchemaLibraryService.ListAsync()).ToList();
}
finally
{
_loading = false;
}
}
private void BeginCreate()
{
_editing = true;
_editId = null;
_editOriginalName = string.Empty;
_formName = string.Empty;
_formScope = null;
// Seed an object-shaped schema so the builder opens with a useful default.
_formSchemaJson = """{"type":"object","properties":{}}""";
_formError = null;
}
private void BeginEdit(SharedSchema schema)
{
_editing = true;
_editId = schema.Id;
_editOriginalName = schema.Name;
_formName = schema.Name;
_formScope = schema.Scope;
_formSchemaJson = schema.SchemaJson;
_formError = null;
}
private void CancelEdit()
{
_editing = false;
_formError = null;
}
private async Task SaveSchema()
{
_formError = null;
var name = _formName?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(name))
{
_formError = "Schema name is required.";
return;
}
var scope = string.IsNullOrWhiteSpace(_formScope) ? null : _formScope.Trim();
var schemaJson = _formSchemaJson ?? string.Empty;
_busy = true;
try
{
var result = _editId.HasValue
? await SchemaLibraryService.UpdateAsync(_editId.Value, name, scope, schemaJson)
: await SchemaLibraryService.CreateAsync(name, scope, schemaJson);
if (result.Success)
{
_toast.ShowSuccess(_editId.HasValue
? $"Schema '{name}' updated."
: $"Schema '{name}' created.");
_editing = false;
await LoadAsync();
}
else
{
_formError = result.Error;
}
}
finally
{
_busy = false;
}
}
private async Task DeleteSchema(SharedSchema schema)
{
var confirmed = await Dialog.ConfirmAsync(
"Delete Schema",
$"Delete library schema '{schema.Name}'? References to lib:{schema.Name} will no longer resolve.",
danger: true);
if (!confirmed) return;
var result = await SchemaLibraryService.DeleteAsync(schema.Id);
if (result.Success)
{
_toast.ShowSuccess($"Schema '{schema.Name}' deleted.");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error ?? "Delete failed.");
}
}
}
@@ -124,6 +124,23 @@ public static class ServiceCollectionExtensions
// the move dialog only SUBMITS the command and renders the returned outcome.
services.AddScoped<IDataConnectionMoveService, DataConnectionMoveService>();
// Schema Library (M9-T32c): authoring + read accessors for the reusable named
// JSON-Schema library (SharedSchema + ISharedSchemaRepository, T32a).
//
// ISchemaLibraryService dispatches the CRUD commands to the central
// ManagementActor through the in-process ManagementActorHolder seam — the same
// Ask path the HTTP /management endpoint uses — so the server stays the single
// enforcer of the Designer gate, the unique-name guard, and JSON-Schema
// validation; the page only SUBMITS commands.
services.AddScoped<ISchemaLibraryService, SchemaLibraryService>();
// ISchemaLibraryQueryService is the READ counterpart that T30's schema-driven
// value-entry forms reuse to resolve {"$ref":"lib:Name"} pointers — a name →
// schema-JSON map backed by ISharedSchemaRepository over a fresh DI scope per
// query (mirroring the AuditLog / KPI query services, off the circuit-scoped
// DbContext). Read-only; no mutation goes through it.
services.AddScoped<ISchemaLibraryQueryService, SchemaLibraryQueryService>();
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// CentralUI read-accessor over the named JSON-Schema library (M9-T32c) for surfaces
/// that need to RESOLVE <c>{"$ref":"lib:Name"}</c> pointers — chiefly the schema-driven
/// value-entry forms (T30). It exposes the library as a name → schema-JSON map (and the
/// equivalent single-name lookup) so the value form can plug the resolved schema text
/// into <c>InboundApiSchema.Parse(json, name =&gt; query.Resolve(name))</c> exactly the
/// way the deploy-time / runtime resolvers do.
/// </summary>
/// <remarks>
/// This is the READ counterpart of <see cref="ISchemaLibraryService"/> (the authoring /
/// mutation facade). Reads do not need the guard-running ManagementActor path, so this
/// accessor is backed by <c>ISharedSchemaRepository</c> over a fresh DI scope (mirroring
/// the AuditLog / KPI query services), keeping it off the circuit-scoped DbContext.
/// </remarks>
public interface ISchemaLibraryQueryService
{
/// <summary>
/// Returns the whole library as a name → schema-JSON map (name-keyed, ordinal
/// comparison — the same key the <c>lib:Name</c> resolver matches on).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the name → schema-JSON map (empty when the library is empty).</returns>
Task<IReadOnlyDictionary<string, string>> GetSchemaMapAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Returns the schema JSON for a single named library entry, or <c>null</c> when no
/// entry by that name exists. Suitable directly as the <c>resolveRef</c> seam's
/// per-name lookup.
/// </summary>
/// <param name="name">The library entry name (the part after the <c>lib:</c> scheme prefix).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the entry's schema JSON, or <c>null</c> if not found.</returns>
Task<string?> ResolveAsync(string name, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,83 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Outcome of a single schema-library mutation dispatch (create / update / delete).
/// Wraps either success or a human-readable server error (a duplicate-name guard, an
/// invalid-JSON validation failure, or a not-found) so the schema-library page can
/// render an inline result rather than reasoning about transport exceptions or the
/// three management response shapes. Mirrors <see cref="DataConnectionMoveResult"/>.
/// </summary>
/// <param name="Success">Whether the mutation succeeded.</param>
/// <param name="Error">A human-readable error message when <paramref name="Success"/> is <c>false</c>; otherwise <c>null</c>.</param>
public record SchemaLibraryActionResult(bool Success, string? Error)
{
/// <summary>Creates a successful result.</summary>
/// <returns>A successful <see cref="SchemaLibraryActionResult"/>.</returns>
public static SchemaLibraryActionResult Ok() => new(true, null);
/// <summary>Creates a failed result carrying <paramref name="error"/>.</summary>
/// <param name="error">The human-readable failure (typically a server guard / validation message).</param>
/// <returns>A failed <see cref="SchemaLibraryActionResult"/>.</returns>
public static SchemaLibraryActionResult Fail(string error) => new(false, error);
}
/// <summary>
/// CentralUI facade over the schema-library CRUD management commands (M9-T32c). Every
/// mutation (<c>CreateSharedSchemaCommand</c> / <c>UpdateSharedSchemaCommand</c> /
/// <c>DeleteSharedSchemaCommand</c>) and the list query is dispatched to the central
/// <c>ManagementActor</c> through the in-process <c>ManagementActorHolder</c> seam — the
/// SAME Ask path the HTTP <c>/management</c> endpoint uses — so the server remains the
/// single enforcer of the Designer role gate, the unique-name guard, and JSON-Schema
/// validation. The page re-implements none of that: it lists, authors via
/// <c>SchemaBuilder</c>, and SUBMITS commands; any guard / validation failure comes back
/// as a classified <see cref="SchemaLibraryActionResult"/>.
/// </summary>
/// <remarks>
/// The current Blazor principal is projected to an <c>AuthenticatedUser</c> so the
/// server's role gate runs against the real identity. Mirrors
/// <c>IDataConnectionMoveService</c> / <c>ISecuredWriteService</c>.
/// </remarks>
public interface ISchemaLibraryService
{
/// <summary>
/// Lists every library schema (name-ordered) via the read-gated
/// <c>ListSharedSchemasCommand</c>. A read failure yields an empty list (logged) so
/// the page renders gracefully.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the library entries, or an empty list on failure.</returns>
Task<IReadOnlyList<SharedSchema>> ListAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Creates a library schema via the Designer-gated <c>CreateSharedSchemaCommand</c>.
/// </summary>
/// <param name="name">The unique schema name.</param>
/// <param name="scope">An optional scope discriminator (<c>null</c> = global).</param>
/// <param name="schemaJson">The JSON Schema document text.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to a classified create outcome.</returns>
Task<SchemaLibraryActionResult> CreateAsync(
string name, string? scope, string schemaJson, CancellationToken cancellationToken = default);
/// <summary>
/// Updates a library schema via the Designer-gated <c>UpdateSharedSchemaCommand</c>.
/// </summary>
/// <param name="id">Primary key of the schema to update.</param>
/// <param name="name">The (possibly renamed) unique schema name.</param>
/// <param name="scope">An optional scope discriminator (<c>null</c> = global).</param>
/// <param name="schemaJson">The JSON Schema document text.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to a classified update outcome.</returns>
Task<SchemaLibraryActionResult> UpdateAsync(
int id, string name, string? scope, string schemaJson, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a library schema via the Designer-gated <c>DeleteSharedSchemaCommand</c>.
/// </summary>
/// <param name="id">Primary key of the schema to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to a classified delete outcome.</returns>
Task<SchemaLibraryActionResult> DeleteAsync(int id, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,58 @@
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="ISchemaLibraryQueryService"/> implementation (M9-T32c). Reads the
/// named JSON-Schema library directly from <see cref="ISharedSchemaRepository"/> over a
/// fresh DI scope per query — mirroring <c>AuditLogQueryService</c> /
/// <c>KpiHistoryQueryService</c> so a value form's auto-load never races other reads on
/// the shared circuit-scoped DbContext. Read-only; no mutation goes through here.
/// </summary>
public sealed class SchemaLibraryQueryService : ISchemaLibraryQueryService
{
private readonly IServiceScopeFactory _scopeFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SchemaLibraryQueryService"/>.
/// </summary>
/// <param name="scopeFactory">Factory used to open a fresh DI scope (and DbContext) per query.</param>
public SchemaLibraryQueryService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
}
/// <inheritdoc/>
public async Task<IReadOnlyDictionary<string, string>> GetSchemaMapAsync(
CancellationToken cancellationToken = default)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<ISharedSchemaRepository>();
var all = await repo.ListAsync(cancellationToken);
// Ordinal-keyed to match the lib:Name resolver's exact-name lookup. Last-wins on
// the (DB-unique) name guards against a transient duplicate read.
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var schema in all)
{
map[schema.Name] = schema.SchemaJson;
}
return map;
}
/// <inheritdoc/>
public async Task<string?> ResolveAsync(string name, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(name))
{
return null;
}
await using var scope = _scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<ISharedSchemaRepository>();
var schema = await repo.GetByNameAsync(name, cancellationToken);
return schema?.SchemaJson;
}
}
@@ -0,0 +1,165 @@
using System.Security.Claims;
using System.Text.Json;
using Akka.Actor;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="ISchemaLibraryService"/> implementation — a thin facade that
/// dispatches the schema-library CRUD commands to the central <c>ManagementActor</c>
/// through the in-process <see cref="ManagementActorHolder"/> (the same Ask seam the
/// HTTP <c>/management</c> endpoint uses). The actor authorizes the command against the
/// supplied <see cref="AuthenticatedUser"/> (Designer-gated mutations), enforces the
/// unique-name guard, validates the JSON Schema, and writes the audit row — none of
/// that is re-implemented here. Mirrors <see cref="SecuredWriteService"/> /
/// <see cref="DataConnectionMoveService"/>.
/// </summary>
public sealed class SchemaLibraryService : ISchemaLibraryService
{
/// <summary>
/// camelCase + case-insensitive, matching <c>ManagementActor.SerializeResult</c>'s
/// options. <see cref="ManagementSuccess.JsonData"/> is produced with those settings,
/// so the deserializer must mirror them to bind every property.
/// </summary>
private static readonly JsonSerializerOptions ResultDeserializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private static readonly TimeSpan AskTimeout = TimeSpan.FromSeconds(30);
private readonly ManagementActorHolder _holder;
private readonly AuthenticationStateProvider _auth;
private readonly ILogger<SchemaLibraryService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SchemaLibraryService"/>.
/// </summary>
/// <param name="holder">Holder for the central <c>ManagementActor</c> reference.</param>
/// <param name="auth">Authentication state provider used to project the current principal.</param>
/// <param name="logger">Logger instance.</param>
public SchemaLibraryService(
ManagementActorHolder holder,
AuthenticationStateProvider auth,
ILogger<SchemaLibraryService> logger)
{
_holder = holder ?? throw new ArgumentNullException(nameof(holder));
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<IReadOnlyList<SharedSchema>> ListAsync(CancellationToken cancellationToken = default)
{
var response = await SendAsync(new ListSharedSchemasCommand(), cancellationToken);
if (response is ManagementSuccess success)
{
var items = JsonSerializer.Deserialize<List<SharedSchema>>(
success.JsonData, ResultDeserializerOptions);
return items ?? new List<SharedSchema>();
}
// Read path: log + return empty so the list table renders gracefully.
_logger.LogWarning("ListSharedSchemas failed: {Response}", DescribeFailure(response));
return Array.Empty<SharedSchema>();
}
/// <inheritdoc/>
public Task<SchemaLibraryActionResult> CreateAsync(
string name, string? scope, string schemaJson, CancellationToken cancellationToken = default)
=> DispatchAsync(new CreateSharedSchemaCommand(name, scope, schemaJson), cancellationToken);
/// <inheritdoc/>
public Task<SchemaLibraryActionResult> UpdateAsync(
int id, string name, string? scope, string schemaJson, CancellationToken cancellationToken = default)
=> DispatchAsync(new UpdateSharedSchemaCommand(id, name, scope, schemaJson), cancellationToken);
/// <inheritdoc/>
public Task<SchemaLibraryActionResult> DeleteAsync(int id, CancellationToken cancellationToken = default)
=> DispatchAsync(new DeleteSharedSchemaCommand(id), cancellationToken);
/// <summary>
/// Dispatches a single mutating command and maps the response (or any fault) to a
/// typed <see cref="SchemaLibraryActionResult"/>.
/// </summary>
private async Task<SchemaLibraryActionResult> DispatchAsync(
object command, CancellationToken cancellationToken)
{
var response = await SendAsync(command, cancellationToken);
return response switch
{
ManagementSuccess => SchemaLibraryActionResult.Ok(),
ManagementUnauthorized unauthorized => SchemaLibraryActionResult.Fail(unauthorized.Message),
ManagementError error => SchemaLibraryActionResult.Fail(error.Error),
_ => SchemaLibraryActionResult.Fail(DescribeFailure(response)),
};
}
/// <summary>
/// Wraps <paramref name="command"/> in a <see cref="ManagementEnvelope"/> for the
/// current principal and Asks the <c>ManagementActor</c>. Transport faults (timeout,
/// actor not yet started, cancellation→propagated) become a synthetic
/// <see cref="ManagementError"/> so callers handle one response shape.
/// </summary>
private async Task<object> SendAsync(object command, CancellationToken cancellationToken)
{
var actor = _holder.ActorRef;
if (actor is null)
{
return new ManagementError(
string.Empty, "Management service is not ready.", "SERVICE_UNAVAILABLE");
}
var user = await BuildAuthenticatedUserAsync();
var envelope = new ManagementEnvelope(user, command, Guid.NewGuid().ToString("N"));
try
{
return await actor.Ask<object>(envelope, AskTimeout, cancellationToken);
}
catch (OperationCanceledException)
{
// Caller-initiated cancel (e.g. circuit teardown) — propagate cleanly.
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "ManagementActor Ask failed for {Command}", command.GetType().Name);
return new ManagementError(string.Empty, ex.Message, "TRANSPORT_ERROR");
}
}
/// <summary>
/// Projects the current Blazor <see cref="ClaimsPrincipal"/> to the
/// <see cref="AuthenticatedUser"/> the actor authorizes against — username,
/// display name, role claims, and the permitted-site scope claims (mirrors the
/// claim set the HTTP endpoint constructs).
/// </summary>
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
{
var state = await _auth.GetAuthenticationStateAsync();
var principal = state.User;
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username;
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToArray();
var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray();
return new AuthenticatedUser(username, displayName, roles, permittedSiteIds);
}
/// <summary>Renders a fallback description for an unexpected/failure response.</summary>
private static string DescribeFailure(object response) => response switch
{
ManagementUnauthorized unauthorized => unauthorized.Message,
ManagementError error => error.Error,
_ => "Unexpected response from the management service.",
};
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
// M9-T32c — schema-library authoring commands. The reusable named JSON-Schema
// library (the SharedSchema entity + ISharedSchemaRepository, T32a) gains its CRUD
// surface here. These records travel the same HTTP / ClusterClient management
// boundary as every other *Command and are auto-discovered by reflection in
// ManagementCommandRegistry (no manual registry entry needed).
//
// Gating mirrors the SharedScript / DataConnection idiom: list/get are read-only
// (any authenticated user), create/update/delete are Designer-gated. Name is unique;
// SchemaJson must parse as a JSON Schema (validated server-side in the handler).
public record ListSharedSchemasCommand;
public record GetSharedSchemaCommand(int SharedSchemaId);
public record CreateSharedSchemaCommand(string Name, string? Scope, string SchemaJson);
public record UpdateSharedSchemaCommand(int SharedSchemaId, string Name, string? Scope, string SchemaJson);
public record DeleteSharedSchemaCommand(int SharedSchemaId);
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
@@ -194,6 +195,7 @@ public class ManagementActor : ReceiveActor
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or UpdateAreaCommand
@@ -337,6 +339,13 @@ public class ManagementActor : ReceiveActor
UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user.Username),
DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user.Username),
// Schema Library (M9-T32c)
ListSharedSchemasCommand => await HandleListSharedSchemas(sp),
GetSharedSchemaCommand cmd => await HandleGetSharedSchema(sp, cmd),
CreateSharedSchemaCommand cmd => await HandleCreateSharedSchema(sp, cmd, user.Username),
UpdateSharedSchemaCommand cmd => await HandleUpdateSharedSchema(sp, cmd, user.Username),
DeleteSharedSchemaCommand cmd => await HandleDeleteSharedSchema(sp, cmd, user.Username),
// Database Connections (External System)
ListDatabaseConnectionsCommand => await HandleListDatabaseConnections(sp),
GetDatabaseConnectionCommand cmd => await HandleGetDatabaseConnection(sp, cmd),
@@ -2214,6 +2223,131 @@ public class ManagementActor : ReceiveActor
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
// Schema Library handlers (M9-T32c)
//
// Reusable named JSON-Schema library (SharedSchema + ISharedSchemaRepository,
// T32a). Repo-backed, mirroring the DatabaseConnection handler idiom: direct
// repository writes plus an explicit AuditAsync after each mutation. The unique
// Name is enforced with a clear duplicate-name error and the SchemaJson is
// validated as a parseable JSON Schema (reusing InboundApiSchema.Parse) so a
// malformed library entry never reaches the $ref resolver (T32b) or the
// schema-driven value-entry forms (T30).
// ========================================================================
private static async Task<object?> HandleListSharedSchemas(IServiceProvider sp)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
return await repo.ListAsync();
}
private static async Task<object?> HandleGetSharedSchema(IServiceProvider sp, GetSharedSchemaCommand cmd)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
return await repo.GetByIdAsync(cmd.SharedSchemaId);
}
private static async Task<object?> HandleCreateSharedSchema(IServiceProvider sp, CreateSharedSchemaCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
var name = ValidateSchemaName(cmd.Name);
ValidateSchemaJson(cmd.SchemaJson);
var existing = await repo.GetByNameAsync(name);
if (existing is not null)
{
throw new ManagementCommandException($"A schema named '{name}' already exists.");
}
var schema = new SharedSchema { Name = name, Scope = cmd.Scope, SchemaJson = cmd.SchemaJson };
await repo.AddAsync(schema);
await AuditAsync(sp, user, "Create", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
return schema;
}
private static async Task<object?> HandleUpdateSharedSchema(IServiceProvider sp, UpdateSharedSchemaCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
var name = ValidateSchemaName(cmd.Name);
ValidateSchemaJson(cmd.SchemaJson);
var schema = await repo.GetByIdAsync(cmd.SharedSchemaId)
?? throw new ManagementCommandException($"Schema with ID {cmd.SharedSchemaId} not found.");
// A rename may not collide with a DIFFERENT row's name. The same name on the
// same row is allowed (a schema-body-only edit keeps its name).
var byName = await repo.GetByNameAsync(name);
if (byName is not null && byName.Id != schema.Id)
{
throw new ManagementCommandException($"A schema named '{name}' already exists.");
}
schema.Name = name;
schema.Scope = cmd.Scope;
schema.SchemaJson = cmd.SchemaJson;
await repo.UpdateAsync(schema);
await AuditAsync(sp, user, "Update", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
return schema;
}
private static async Task<object?> HandleDeleteSharedSchema(IServiceProvider sp, DeleteSharedSchemaCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
await repo.DeleteAsync(cmd.SharedSchemaId);
await AuditAsync(sp, user, "Delete", "SharedSchema", cmd.SharedSchemaId.ToString(), cmd.SharedSchemaId.ToString(), null);
return true;
}
/// <summary>
/// Validates and trims a library schema name. A non-empty unique name is the
/// lookup key for the <c>lib:Name</c> <c>$ref</c> resolver, so an empty name is a
/// caller-safe validation error (surfaced verbatim via
/// <see cref="ManagementCommandException"/>).
/// </summary>
private static string ValidateSchemaName(string? rawName)
{
var name = rawName?.Trim();
if (string.IsNullOrEmpty(name))
{
throw new ManagementCommandException("Schema name is required.");
}
return name;
}
/// <summary>
/// Validates that <paramref name="schemaJson"/> parses as a JSON Schema, reusing the
/// authoritative <see cref="InboundApiSchema.ParseWithRefs"/> parser so a library
/// entry obeys the same STRUCTURAL rules as every other stored schema (object form or
/// legacy flat-array; over-depth and root-scalar inputs rejected). A structural parse
/// failure is re-thrown as a caller-safe <see cref="ManagementCommandException"/>
/// naming the fault.
/// <para>
/// The <c>ParseWithRefs</c> (collecting) variant is used deliberately so a library
/// entry that itself carries a <c>{"$ref":"lib:Other"}</c> pointer is NOT rejected at
/// save time merely because the resolver is not consulted here — a dangling/cyclic
/// ref surfaces later on the deploy-time / runtime resolution path (T32b), exactly
/// like a forward reference to a not-yet-created entry. Only malformed JSON is a
/// save-blocking error.
/// </para>
/// </summary>
private static void ValidateSchemaJson(string? schemaJson)
{
if (string.IsNullOrWhiteSpace(schemaJson))
{
throw new ManagementCommandException("Schema JSON is required.");
}
try
{
_ = InboundApiSchema.ParseWithRefs(schemaJson, resolveRef: null);
}
catch (JsonException ex)
{
throw new ManagementCommandException($"Schema JSON is not a valid JSON Schema: {ex.Message}");
}
}
// ========================================================================
// Database Connection Definition handlers
// ========================================================================