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
@@ -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
// ========================================================================