feat(m9/T24a): guarded move-data-connection-between-sites command + handler

This commit is contained in:
Joseph Doherty
2026-06-18 11:20:58 -04:00
parent e6191ec55a
commit 48111b50fd
5 changed files with 299 additions and 3 deletions
@@ -187,7 +187,7 @@ public class ManagementActor : ReceiveActor
or DeleteNotificationListCommand
or UpdateSmtpConfigCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand
or DeleteDataConnectionCommand or MoveDataConnectionCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
@@ -306,6 +306,7 @@ public class ManagementActor : ReceiveActor
CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
MoveDataConnectionCommand cmd => await HandleMoveDataConnection(sp, cmd, user.Username),
// External Systems
ListExternalSystemsCommand => await HandleListExternalSystems(sp),
@@ -1373,6 +1374,108 @@ public class ManagementActor : ReceiveActor
return true;
}
/// <summary>
/// Moves a data connection to another site (M9 / T24a). High-risk + data
/// integrity: every guard runs server-side BEFORE the write, and when in
/// doubt the move is BLOCKED with a clear error rather than risking an
/// orphaned binding/reference. No bindings or name references are rewritten.
/// </summary>
private static async Task<object?> HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user)
{
var repo = sp.GetRequiredService<ISiteRepository>();
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
var sourceSiteId = conn.SiteId;
if (sourceSiteId == cmd.TargetSiteId)
throw new ManagementCommandException(
$"DataConnection '{conn.Name}' (ID {conn.Id}) already belongs to site {cmd.TargetSiteId}.");
// Guard 1: target site must exist.
var targetSite = await repo.GetSiteByIdAsync(cmd.TargetSiteId)
?? throw new ManagementCommandException($"Target site with ID {cmd.TargetSiteId} not found.");
// Guard 2: no name collision at the target site.
var targetConnections = await repo.GetDataConnectionsBySiteIdAsync(cmd.TargetSiteId);
if (targetConnections.Any(c => c.Id != conn.Id
&& string.Equals(c.Name, conn.Name, StringComparison.OrdinalIgnoreCase)))
{
throw new ManagementCommandException(
$"Target site '{targetSite.Name}' (ID {cmd.TargetSiteId}) already has a data connection named '{conn.Name}'. " +
"Rename one of them before moving.");
}
// Guard 3 (primary data-integrity guard): no InstanceConnectionBinding may
// reference the connection. Instances are site-scoped, so a bound
// connection cannot leave its site without orphaning the binding.
var blockingInstances = await repo.GetInstancesReferencingDataConnectionAsync(conn.Id);
if (blockingInstances.Count > 0)
{
var names = string.Join(", ", blockingInstances
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(i => $"'{i.UniqueName}' (ID {i.Id})"));
throw new ManagementCommandException(
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): it is referenced by " +
$"{blockingInstances.Count} instance binding(s) on its current site. Rebind or delete the " +
$"following instance(s) first: {names}.");
}
// Guard 4 (name-based native-alarm-source references): templates reference
// a connection by Name (TemplateNativeAlarmSource.ConnectionName) and
// instances may override that name
// (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a
// connection changes which physical connection that name resolves to on
// the source site, so any such reference to the moving name is a blocker.
// Detect and report only — never auto-rewrite.
var referenceBlockers = new List<string>();
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
var templates = await templateRepo.GetAllTemplatesAsync();
foreach (var template in templates)
{
foreach (var source in template.NativeAlarmSources)
{
if (string.Equals(source.ConnectionName, conn.Name, StringComparison.OrdinalIgnoreCase))
{
referenceBlockers.Add(
$"template '{template.Name}' native-alarm-source '{source.Name}'");
}
}
}
// Instance-level overrides on the SOURCE site that name this connection
// would orphan once the connection leaves the site.
var sourceInstances = await repo.GetInstancesBySiteIdAsync(sourceSiteId);
foreach (var instance in sourceInstances)
{
foreach (var ovr in instance.NativeAlarmSourceOverrides)
{
if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.OrdinalIgnoreCase))
{
referenceBlockers.Add(
$"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'");
}
}
}
if (referenceBlockers.Count > 0)
{
var details = string.Join(", ", referenceBlockers);
throw new ManagementCommandException(
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): its name is referenced by " +
$"native-alarm-source binding(s) that would be orphaned or made ambiguous by the move. " +
$"Resolve the following reference(s) first: {details}.");
}
// All guards passed: persist the site change and audit.
conn.SiteId = cmd.TargetSiteId;
await repo.UpdateDataConnectionAsync(conn);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Move", "DataConnection", conn.Id.ToString(), conn.Name, conn);
return conn;
}
// ========================================================================
// External System handlers