refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Hierarchical area management per site.
|
||||
/// - CRUD for areas with parent-child relationships
|
||||
/// - Deletion constrained if instances are assigned
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class AreaService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AreaService with the specified repository and audit service.
|
||||
/// </summary>
|
||||
/// <param name="repository">The template engine repository for data access.</param>
|
||||
/// <param name="auditService">The audit service for logging area changes.</param>
|
||||
public AreaService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new area within a site.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the new area.</param>
|
||||
/// <param name="siteId">The site identifier.</param>
|
||||
/// <param name="parentAreaId">Optional parent area identifier for hierarchical organization.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<Area>> CreateAreaAsync(
|
||||
string name, int siteId, int? parentAreaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
// Validate parent area if specified
|
||||
if (parentAreaId.HasValue)
|
||||
{
|
||||
var parent = await _repository.GetAreaByIdAsync(parentAreaId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} not found.");
|
||||
if (parent.SiteId != siteId)
|
||||
return Result<Area>.Failure($"Parent area with ID {parentAreaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.ParentAreaId == parentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level in site {siteId}.");
|
||||
|
||||
var area = new Area(name)
|
||||
{
|
||||
SiteId = siteId,
|
||||
ParentAreaId = parentAreaId
|
||||
};
|
||||
|
||||
await _repository.AddAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an area's name.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="name">The new name for the area.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<Area>> UpdateAreaAsync(
|
||||
int areaId, string name, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Area>.Failure("Area name is required.");
|
||||
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for duplicate name at the same level
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var duplicate = siblings.FirstOrDefault(a =>
|
||||
a.Id != areaId &&
|
||||
a.ParentAreaId == area.ParentAreaId &&
|
||||
string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate != null)
|
||||
return Result<Area>.Failure($"An area named '{name}' already exists at this level.");
|
||||
|
||||
area.Name = name;
|
||||
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Area", area.Id.ToString(), name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-parents an area within its site. `newParentAreaId == null` moves the area to the site root.
|
||||
/// Rejects: self-parent, descendant-parent (cycle), cross-site parent, name collision at new level.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="newParentAreaId">The new parent area identifier, or null to move to site root.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<Area>> MoveAreaAsync(
|
||||
int areaId, int? newParentAreaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Area>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
if (newParentAreaId == areaId)
|
||||
return Result<Area>.Failure("An area cannot be its own parent.");
|
||||
|
||||
if (newParentAreaId.HasValue)
|
||||
{
|
||||
var newParent = await _repository.GetAreaByIdAsync(newParentAreaId.Value, cancellationToken);
|
||||
if (newParent == null)
|
||||
return Result<Area>.Failure($"Target parent area with ID {newParentAreaId.Value} not found.");
|
||||
if (newParent.SiteId != area.SiteId)
|
||||
return Result<Area>.Failure("Areas can only be moved within the same site.");
|
||||
}
|
||||
|
||||
var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
|
||||
// Cycle prevention: the new parent must not be a descendant of the area being moved.
|
||||
if (newParentAreaId.HasValue)
|
||||
{
|
||||
var descendants = GetDescendantAreaIds(areaId, siblings);
|
||||
if (descendants.Contains(newParentAreaId.Value))
|
||||
return Result<Area>.Failure(
|
||||
$"Cannot move area '{area.Name}' under one of its own descendants.");
|
||||
}
|
||||
|
||||
if (newParentAreaId == area.ParentAreaId)
|
||||
return Result<Area>.Success(area);
|
||||
|
||||
var collision = siblings.FirstOrDefault(a =>
|
||||
a.Id != areaId &&
|
||||
a.ParentAreaId == newParentAreaId &&
|
||||
string.Equals(a.Name, area.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (collision != null)
|
||||
return Result<Area>.Failure(
|
||||
$"An area named '{area.Name}' already exists at the target level.");
|
||||
|
||||
area.ParentAreaId = newParentAreaId;
|
||||
await _repository.UpdateAreaAsync(area, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Move", "Area", area.Id.ToString(), area.Name, area, cancellationToken);
|
||||
|
||||
return Result<Area>.Success(area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an area. Blocked if instances are assigned to this area or any descendant area.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="user">The user performing the action for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<bool>> DeleteAreaAsync(
|
||||
int areaId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<bool>.Failure($"Area with ID {areaId} not found.");
|
||||
|
||||
// Check for instances assigned to this area
|
||||
var allInstances = await _repository.GetInstancesBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
var allAreas = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken);
|
||||
|
||||
// Collect this area and all descendant area IDs
|
||||
var descendantIds = GetDescendantAreaIds(areaId, allAreas);
|
||||
descendantIds.Add(areaId);
|
||||
|
||||
var assignedInstances = allInstances
|
||||
.Where(i => i.AreaId.HasValue && descendantIds.Contains(i.AreaId.Value))
|
||||
.ToList();
|
||||
|
||||
if (assignedInstances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", assignedInstances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': {assignedInstances.Count} instance(s) are assigned to it or its sub-areas ({names}{(assignedInstances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check for child areas (must delete children first, or we delete recursively)
|
||||
var childAreas = allAreas.Where(a => a.ParentAreaId == areaId).ToList();
|
||||
if (childAreas.Count > 0)
|
||||
{
|
||||
var childNames = string.Join(", ", childAreas.Select(a => a.Name));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete area '{area.Name}': it has child areas ({childNames}). Delete child areas first.");
|
||||
}
|
||||
|
||||
await _repository.DeleteAreaAsync(areaId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Area", areaId.ToString(), area.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all areas for a site.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single area by ID.
|
||||
/// </summary>
|
||||
/// <param name="areaId">The area identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Area?> GetAreaByIdAsync(int areaId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAreaByIdAsync(areaId, cancellationToken);
|
||||
|
||||
private static HashSet<int> GetDescendantAreaIds(int parentId, IReadOnlyList<Area> allAreas)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(parentId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var child in allAreas.Where(a => a.ParentAreaId == current))
|
||||
{
|
||||
if (result.Add(child.Id))
|
||||
queue.Enqueue(child.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Instance CRUD operations.
|
||||
/// - Create instance from template at site
|
||||
/// - Assign to area
|
||||
/// - Override non-locked attribute values
|
||||
/// - Cannot add or remove attributes (only override existing ones)
|
||||
/// - Per-attribute connection binding (bulk assignment support)
|
||||
/// - Enabled/disabled state. Concurrent edits are last-write-wins — there is no
|
||||
/// version token or conflict detection on instance state, matching the design
|
||||
/// decision (Component-TemplateEngine.md: "Concurrent editing uses
|
||||
/// last-write-wins — no pessimistic locking or conflict detection"). Optimistic
|
||||
/// concurrency in the system applies to deployment status records, not here.
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class InstanceService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the InstanceService class.
|
||||
/// </summary>
|
||||
/// <param name="repository">Template engine repository for data access.</param>
|
||||
/// <param name="auditService">Service for audit logging.</param>
|
||||
public InstanceService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance from a template at a site.
|
||||
/// </summary>
|
||||
/// <param name="uniqueName">Unique name for the instance.</param>
|
||||
/// <param name="templateId">ID of the template to instantiate.</param>
|
||||
/// <param name="siteId">ID of the site where the instance will reside.</param>
|
||||
/// <param name="areaId">Optional ID of the area to assign to the instance.</param>
|
||||
/// <param name="user">Username of the user creating the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the created instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> CreateInstanceAsync(
|
||||
string uniqueName,
|
||||
int templateId,
|
||||
int siteId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uniqueName))
|
||||
return Result<Instance>.Failure("Instance unique name is required.");
|
||||
|
||||
// Verify template exists
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<Instance>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Check for duplicate unique name
|
||||
var existing = await _repository.GetInstanceByUniqueNameAsync(uniqueName, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Instance>.Failure($"Instance with unique name '{uniqueName}' already exists.");
|
||||
|
||||
// Verify area exists if specified
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != siteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to site {siteId}.");
|
||||
}
|
||||
|
||||
var instance = new Instance(uniqueName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
SiteId = siteId,
|
||||
AreaId = areaId,
|
||||
State = InstanceState.Disabled // New instances start disabled
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Instance", instance.Id.ToString(),
|
||||
uniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns an instance to an area.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="areaId">ID of the area to assign to, or null to clear assignment.</param>
|
||||
/// <param name="user">Username of the user making the assignment.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the updated instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> AssignToAreaAsync(
|
||||
int instanceId,
|
||||
int? areaId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
if (areaId.HasValue)
|
||||
{
|
||||
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
|
||||
if (area == null)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
|
||||
if (area.SiteId != instance.SiteId)
|
||||
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to the instance's site.");
|
||||
}
|
||||
|
||||
instance.AreaId = areaId;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "AssignArea", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute override for an instance. Only non-locked attributes can be overridden.
|
||||
/// Cannot add or remove attributes — only override values of existing template attributes.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="attributeName">Name of the attribute to override.</param>
|
||||
/// <param name="overrideValue">Override value, or null to clear.</param>
|
||||
/// <param name="user">Username of the user setting the override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the attribute override or failure message.</returns>
|
||||
public async Task<Result<InstanceAttributeOverride>> SetAttributeOverrideAsync(
|
||||
int instanceId,
|
||||
string attributeName,
|
||||
string? overrideValue,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAttributeOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify attribute exists in the template and is not locked
|
||||
var templateAttrs = await _repository.GetAttributesByTemplateIdAsync(instance.TemplateId, cancellationToken);
|
||||
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
|
||||
if (templateAttr == null)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides.");
|
||||
|
||||
if (templateAttr.IsLocked)
|
||||
return Result<InstanceAttributeOverride>.Failure(
|
||||
$"Attribute '{attributeName}' is locked and cannot be overridden.");
|
||||
|
||||
// Find existing override or create new one
|
||||
var overrides = await _repository.GetOverridesByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingOverride = overrides.FirstOrDefault(o => o.AttributeName == attributeName);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.OverrideValue = overrideValue;
|
||||
await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateOverride", "InstanceAttributeOverride",
|
||||
existingOverride.Id.ToString(), attributeName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAttributeOverride(attributeName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
OverrideValue = overrideValue
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateOverride", "InstanceAttributeOverride",
|
||||
newOverride.Id.ToString(), attributeName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAttributeOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a per-instance alarm override. The alarm must exist in the
|
||||
/// instance's effective alarm set (direct, inherited, or composed) and
|
||||
/// must not be locked. For HiLo alarms, the override JSON merges into the
|
||||
/// inherited TriggerConfiguration setpoint-by-setpoint; for binary trigger
|
||||
/// types, it replaces the whole config.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="alarmCanonicalName">Canonical name of the alarm to override.</param>
|
||||
/// <param name="triggerConfigurationOverride">Override JSON for the trigger configuration.</param>
|
||||
/// <param name="priorityLevelOverride">Override priority level, or null to use template value.</param>
|
||||
/// <param name="user">Username of the user setting the override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the alarm override or failure message.</returns>
|
||||
public async Task<Result<InstanceAlarmOverride>> SetAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string? triggerConfigurationOverride,
|
||||
int? priorityLevelOverride,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceAlarmOverride>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Verify the alarm exists in the instance's effective alarm set and is
|
||||
// not locked. The effective set is resolved via TemplateResolver so that
|
||||
// composed (path-qualified) and inherited alarms are found — a lookup
|
||||
// against the template's direct alarms alone would miss them, silently
|
||||
// accepting an override for a non-existent name or bypassing the lock
|
||||
// rule for a composed alarm. Mirrors SetAttributeOverrideAsync.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var resolvedAlarm = TemplateResolver
|
||||
.ResolveAllMembers(instance.TemplateId, allTemplates)
|
||||
.FirstOrDefault(m => m.MemberType == "Alarm" && m.CanonicalName == alarmCanonicalName);
|
||||
|
||||
if (resolvedAlarm == null)
|
||||
return Result<InstanceAlarmOverride>.Failure(
|
||||
$"Alarm '{alarmCanonicalName}' does not exist in template {instance.TemplateId}. " +
|
||||
"Cannot override an unknown alarm.");
|
||||
|
||||
if (resolvedAlarm.IsLocked)
|
||||
return Result<InstanceAlarmOverride>.Failure(
|
||||
$"Alarm '{alarmCanonicalName}' is locked and cannot be overridden.");
|
||||
|
||||
var existingOverride = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
|
||||
if (existingOverride != null)
|
||||
{
|
||||
existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride;
|
||||
existingOverride.PriorityLevelOverride = priorityLevelOverride;
|
||||
await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "UpdateAlarmOverride", "InstanceAlarmOverride",
|
||||
existingOverride.Id.ToString(), alarmCanonicalName, existingOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(existingOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newOverride = new InstanceAlarmOverride(alarmCanonicalName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
TriggerConfigurationOverride = triggerConfigurationOverride,
|
||||
PriorityLevelOverride = priorityLevelOverride
|
||||
};
|
||||
|
||||
await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "CreateAlarmOverride", "InstanceAlarmOverride",
|
||||
newOverride.Id.ToString(), alarmCanonicalName, newOverride, cancellationToken);
|
||||
|
||||
return Result<InstanceAlarmOverride>.Success(newOverride);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a per-instance alarm override. After removal the instance
|
||||
/// inherits the template alarm config unchanged.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="alarmCanonicalName">Canonical name of the alarm to delete the override for.</param>
|
||||
/// <param name="user">Username of the user deleting the override.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result indicating success or failure.</returns>
|
||||
public async Task<Result<bool>> DeleteAlarmOverrideAsync(
|
||||
int instanceId,
|
||||
string alarmCanonicalName,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await _repository.GetAlarmOverrideAsync(
|
||||
instanceId, alarmCanonicalName, cancellationToken);
|
||||
if (existing == null)
|
||||
return Result<bool>.Failure($"No alarm override for '{alarmCanonicalName}' on instance {instanceId}.");
|
||||
|
||||
await _repository.DeleteInstanceAlarmOverrideAsync(existing.Id, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "DeleteAlarmOverride", "InstanceAlarmOverride",
|
||||
existing.Id.ToString(), alarmCanonicalName, existing, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets connection bindings for an instance in bulk.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="bindings">Connection bindings to set.</param>
|
||||
/// <param name="user">Username of the user setting the bindings.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the updated bindings or failure message.</returns>
|
||||
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
|
||||
int instanceId,
|
||||
IReadOnlyList<ConnectionBinding> bindings,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
var existingBindings = await _repository.GetBindingsByInstanceIdAsync(instanceId, cancellationToken);
|
||||
var existingMap = existingBindings.ToDictionary(b => b.AttributeName, StringComparer.Ordinal);
|
||||
|
||||
var results = new List<InstanceConnectionBinding>();
|
||||
|
||||
foreach (var (attrName, connId) in bindings)
|
||||
{
|
||||
if (existingMap.TryGetValue(attrName, out var existing))
|
||||
{
|
||||
existing.DataConnectionId = connId;
|
||||
await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken);
|
||||
results.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var binding = new InstanceConnectionBinding(attrName)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
DataConnectionId = connId
|
||||
};
|
||||
await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken);
|
||||
results.Add(binding);
|
||||
}
|
||||
}
|
||||
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "SetConnectionBindings", "Instance",
|
||||
instance.Id.ToString(), instance.UniqueName, bindings, cancellationToken);
|
||||
|
||||
return Result<IReadOnlyList<InstanceConnectionBinding>>.Success(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="user">Username of the user enabling the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the enabled instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> EnableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Enabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Enable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="user">Username of the user disabling the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing the disabled instance or failure message.</returns>
|
||||
public async Task<Result<Instance>> DisableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
instance.State = InstanceState.Disabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Disable", "Instance", instance.Id.ToString(),
|
||||
instance.UniqueName, instance, cancellationToken);
|
||||
|
||||
return Result<Instance>.Success(instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="user">Username of the user deleting the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result indicating success or failure.</returns>
|
||||
public async Task<Result<bool>> DeleteAsync(int instanceId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<bool>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an instance by ID.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">ID of the instance.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The instance, or null if not found.</returns>
|
||||
public async Task<Instance?> GetByIdAsync(int instanceId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all instances for a site.
|
||||
/// </summary>
|
||||
/// <param name="siteId">ID of the site.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of instances for the site.</returns>
|
||||
public async Task<IReadOnlyList<Instance>> GetBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Site and data connection management.
|
||||
/// - Site CRUD (name, identifier, description)
|
||||
/// - Data connection CRUD (name, protocol, config) — each connection belongs to exactly one site
|
||||
/// - Audit logging
|
||||
/// </summary>
|
||||
public class SiteService
|
||||
{
|
||||
private readonly ISiteRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>Initializes the service with its repository and audit collaborators.</summary>
|
||||
/// <param name="repository">Repository for site and data connection persistence.</param>
|
||||
/// <param name="auditService">Audit service for logging CRUD operations.</param>
|
||||
public SiteService(ISiteRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
// --- Site CRUD ---
|
||||
|
||||
/// <summary>Creates a new site with the given name and identifier, rejecting duplicates.</summary>
|
||||
/// <param name="name">Display name of the site.</param>
|
||||
/// <param name="siteIdentifier">Unique machine-readable site identifier.</param>
|
||||
/// <param name="description">Optional description.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the new <see cref="Site"/>, or a failure result with a message.</returns>
|
||||
public async Task<Result<Site>> CreateSiteAsync(
|
||||
string name, string siteIdentifier, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<Site>.Failure("Site name is required.");
|
||||
if (string.IsNullOrWhiteSpace(siteIdentifier))
|
||||
return Result<Site>.Failure("Site identifier is required.");
|
||||
|
||||
var existing = await _repository.GetSiteByIdentifierAsync(siteIdentifier, cancellationToken);
|
||||
if (existing != null)
|
||||
return Result<Site>.Failure($"Site with identifier '{siteIdentifier}' already exists.");
|
||||
|
||||
var site = new Site(name, siteIdentifier) { Description = description };
|
||||
await _repository.AddSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
/// <summary>Updates the name and description of an existing site.</summary>
|
||||
/// <param name="siteId">Primary key of the site to update.</param>
|
||||
/// <param name="name">New display name.</param>
|
||||
/// <param name="description">New optional description.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the updated <see cref="Site"/>, or a failure result if not found.</returns>
|
||||
public async Task<Result<Site>> UpdateSiteAsync(
|
||||
int siteId, string name, string? description, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<Site>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
site.Name = name;
|
||||
site.Description = description;
|
||||
await _repository.UpdateSiteAsync(site, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "Site", site.Id.ToString(), name, site, cancellationToken);
|
||||
|
||||
return Result<Site>.Success(site);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a site, rejecting the request if any instances are assigned to it.</summary>
|
||||
/// <param name="siteId">Primary key of the site to delete.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result with <c>true</c>, or a failure result with a message if blocked.</returns>
|
||||
public async Task<Result<bool>> DeleteSiteAsync(int siteId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var site = await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
return Result<bool>.Failure($"Site with ID {siteId} not found.");
|
||||
|
||||
// Check for instances assigned to this site
|
||||
var instances = await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(5));
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete site '{site.Name}': {instances.Count} instance(s) are assigned to it ({names}{(instances.Count > 5 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
await _repository.DeleteSiteAsync(siteId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Site", siteId.ToString(), site.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>Returns the site with the given primary key, or <c>null</c> if not found.</summary>
|
||||
/// <param name="siteId">Primary key of the site.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Site?> GetSiteByIdAsync(int siteId, CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
|
||||
/// <summary>Returns all sites.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default) =>
|
||||
await _repository.GetAllSitesAsync(cancellationToken);
|
||||
|
||||
// --- Data Connection CRUD ---
|
||||
|
||||
/// <summary>Creates a new data connection owned by the specified site.</summary>
|
||||
/// <param name="siteId">Primary key of the owning site.</param>
|
||||
/// <param name="name">Display name of the data connection.</param>
|
||||
/// <param name="protocol">Protocol identifier (e.g., "OpcUa").</param>
|
||||
/// <param name="primaryConfiguration">JSON configuration for the primary endpoint; may be null.</param>
|
||||
/// <param name="backupConfiguration">JSON configuration for the backup endpoint; may be null.</param>
|
||||
/// <param name="failoverRetryCount">Number of retries before switching to the backup endpoint.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the new <see cref="DataConnection"/>, or a failure result.</returns>
|
||||
public async Task<Result<DataConnection>> CreateDataConnectionAsync(
|
||||
int siteId, string name, string protocol, string? primaryConfiguration,
|
||||
string? backupConfiguration, int failoverRetryCount, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<DataConnection>.Failure("Data connection name is required.");
|
||||
if (string.IsNullOrWhiteSpace(protocol))
|
||||
return Result<DataConnection>.Failure("Protocol is required.");
|
||||
|
||||
var connection = new DataConnection(name, protocol, siteId)
|
||||
{
|
||||
PrimaryConfiguration = primaryConfiguration,
|
||||
BackupConfiguration = backupConfiguration,
|
||||
FailoverRetryCount = failoverRetryCount
|
||||
};
|
||||
await _repository.AddDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Create", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
/// <summary>Updates an existing data connection's configuration.</summary>
|
||||
/// <param name="connectionId">Primary key of the data connection to update.</param>
|
||||
/// <param name="name">New display name.</param>
|
||||
/// <param name="protocol">New protocol identifier.</param>
|
||||
/// <param name="primaryConfiguration">New primary endpoint JSON configuration; may be null.</param>
|
||||
/// <param name="backupConfiguration">New backup endpoint JSON configuration; may be null.</param>
|
||||
/// <param name="failoverRetryCount">New failover retry count.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result containing the updated <see cref="DataConnection"/>, or a failure result if not found.</returns>
|
||||
public async Task<Result<DataConnection>> UpdateDataConnectionAsync(
|
||||
int connectionId, string name, string protocol, string? primaryConfiguration,
|
||||
string? backupConfiguration, int failoverRetryCount, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<DataConnection>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
connection.Name = name;
|
||||
connection.Protocol = protocol;
|
||||
connection.PrimaryConfiguration = primaryConfiguration;
|
||||
connection.BackupConfiguration = backupConfiguration;
|
||||
connection.FailoverRetryCount = failoverRetryCount;
|
||||
await _repository.UpdateDataConnectionAsync(connection, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Update", "DataConnection",
|
||||
connection.Id.ToString(), name, connection, cancellationToken);
|
||||
|
||||
return Result<DataConnection>.Success(connection);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a data connection by its primary key.</summary>
|
||||
/// <param name="connectionId">Primary key of the data connection to delete.</param>
|
||||
/// <param name="user">Authenticated username for audit logging.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A successful result with <c>true</c>, or a failure result if not found.</returns>
|
||||
public async Task<Result<bool>> DeleteDataConnectionAsync(
|
||||
int connectionId, string user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
|
||||
if (connection == null)
|
||||
return Result<bool>.Failure($"Data connection with ID {connectionId} not found.");
|
||||
|
||||
await _repository.DeleteDataConnectionAsync(connectionId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "DataConnection",
|
||||
connectionId.ToString(), connection.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces template deletion constraints (WP-25).
|
||||
/// Template deletion is blocked when:
|
||||
/// - Instances reference the template
|
||||
/// - Child templates reference it (as parent)
|
||||
/// - Other templates compose it
|
||||
/// Returns clear error messages listing the referencing entities.
|
||||
/// </summary>
|
||||
public class TemplateDeletionService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="TemplateDeletionService"/> with the given repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository used to check deletion constraints and perform the delete.</param>
|
||||
public TemplateDeletionService(ITemplateEngineRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a template can be safely deleted and returns any blocking reasons.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The id of the template to check.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<bool>> CanDeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
if (template.IsDerived)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is a derived template. " +
|
||||
"Remove the owning composition on its parent template instead.");
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check 1: Instances reference this template
|
||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", instances.Select(i => i.UniqueName).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 2: Child templates reference it as parent. Split derived vs. regular.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
||||
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
||||
|
||||
if (regularChildren.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", regularChildren.Select(t => t.Name).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {regularChildren.Count} child template(s) inherit from it ({names}{(regularChildren.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
if (derivatives.Count > 0)
|
||||
{
|
||||
var compIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
|
||||
var ownerLookup = allTemplates
|
||||
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
|
||||
.Where(x => compIds.Contains(x.Composition.Id))
|
||||
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
||||
var details = string.Join(", ", derivatives.Take(10).Select(d =>
|
||||
d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
||||
? label
|
||||
: $"'{d.Name}'"));
|
||||
errors.Add($"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in {details}{(derivatives.Count > 10 ? "..." : "")}. Remove those compositions first.");
|
||||
}
|
||||
|
||||
// Check 3: Other templates compose it directly (e.g., pre-Phase-3 data).
|
||||
// Read the Compositions navigation already loaded by GetAllTemplatesAsync
|
||||
// rather than issuing one GetCompositionsByTemplateIdAsync round-trip per
|
||||
// template (TemplateEngine-009) — this is the same source TemplateService
|
||||
// .DeleteTemplateAsync uses for the equivalent check.
|
||||
var composingTemplates = allTemplates
|
||||
.SelectMany(t => t.Compositions
|
||||
.Where(comp => comp.ComposedTemplateId == templateId)
|
||||
.Select(comp => (TemplateName: t.Name, comp.InstanceName)))
|
||||
.ToList();
|
||||
|
||||
if (composingTemplates.Count > 0)
|
||||
{
|
||||
var details = string.Join(", ",
|
||||
composingTemplates.Take(10).Select(c => $"'{c.TemplateName}' (as '{c.InstanceName}')"));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {composingTemplates.Count} template(s) compose it ({details}{(composingTemplates.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
return Result<bool>.Failure(string.Join(" ", errors));
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a template after checking all constraints.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The id of the template to delete.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<bool>> DeleteTemplateAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var canDelete = await CanDeleteTemplateAsync(templateId, cancellationToken);
|
||||
if (canDelete.IsFailure)
|
||||
return canDelete;
|
||||
|
||||
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
|
||||
|
||||
public class TemplateFolderService
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repository;
|
||||
private readonly IAuditService _auditService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with its required dependencies.
|
||||
/// </summary>
|
||||
/// <param name="repository">Template engine repository for folder persistence.</param>
|
||||
/// <param name="auditService">Audit service for logging folder operations.</param>
|
||||
public TemplateFolderService(ITemplateEngineRepository repository, IAuditService auditService)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new template folder, enforcing name uniqueness within the parent.
|
||||
/// </summary>
|
||||
/// <param name="name">Display name for the new folder.</param>
|
||||
/// <param name="parentFolderId">Parent folder id, or null to create at root level.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<TemplateFolder>> CreateFolderAsync(
|
||||
string name, int? parentFolderId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Result<TemplateFolder>.Failure("Folder name is required.");
|
||||
|
||||
if (parentFolderId.HasValue)
|
||||
{
|
||||
var parent = await _repository.GetFolderByIdAsync(parentFolderId.Value, cancellationToken);
|
||||
if (parent == null)
|
||||
return Result<TemplateFolder>.Failure($"Parent folder with ID {parentFolderId.Value} not found.");
|
||||
}
|
||||
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
if (all.Any(f => f.ParentFolderId == parentFolderId
|
||||
&& string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{name}' already exists at this level.");
|
||||
|
||||
var folder = new TemplateFolder(name) { ParentFolderId = parentFolderId };
|
||||
await _repository.AddFolderAsync(folder, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateFolder", folder.Id.ToString(), name, folder, cancellationToken);
|
||||
|
||||
return Result<TemplateFolder>.Success(folder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames an existing template folder, enforcing name uniqueness within its parent.
|
||||
/// </summary>
|
||||
/// <param name="folderId">Id of the folder to rename.</param>
|
||||
/// <param name="newName">New display name for the folder.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<TemplateFolder>> RenameFolderAsync(
|
||||
int folderId, string newName, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return Result<TemplateFolder>.Failure("Folder name is required.");
|
||||
|
||||
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
|
||||
if (folder == null)
|
||||
return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");
|
||||
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
if (all.Any(f => f.Id != folderId
|
||||
&& f.ParentFolderId == folder.ParentFolderId
|
||||
&& string.Equals(f.Name, newName, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{newName}' already exists at this level.");
|
||||
|
||||
folder.Name = newName;
|
||||
await _repository.UpdateFolderAsync(folder, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Update", "TemplateFolder", folder.Id.ToString(), newName, folder, cancellationToken);
|
||||
|
||||
return Result<TemplateFolder>.Success(folder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves an existing template folder to a new parent, checking for cycles and name collisions.
|
||||
/// </summary>
|
||||
/// <param name="folderId">Id of the folder to move.</param>
|
||||
/// <param name="newParentId">Target parent folder id, or null to move to root level.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<TemplateFolder>> MoveFolderAsync(
|
||||
int folderId, int? newParentId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
|
||||
if (folder == null)
|
||||
return Result<TemplateFolder>.Failure($"Folder with ID {folderId} not found.");
|
||||
|
||||
if (newParentId.HasValue)
|
||||
{
|
||||
if (newParentId.Value == folderId)
|
||||
return Result<TemplateFolder>.Failure("Cannot move a folder into itself (cycle).");
|
||||
|
||||
var newParent = await _repository.GetFolderByIdAsync(newParentId.Value, cancellationToken);
|
||||
if (newParent == null)
|
||||
return Result<TemplateFolder>.Failure($"Target folder with ID {newParentId.Value} not found.");
|
||||
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
var byId = all.ToDictionary(f => f.Id);
|
||||
var cursor = newParentId;
|
||||
// Walk up from newParentId — if we encounter folderId, the move would create a cycle.
|
||||
// Bound iterations by byId.Count to defensively terminate on a malformed graph.
|
||||
var iterations = 0;
|
||||
while (cursor.HasValue)
|
||||
{
|
||||
if (cursor.Value == folderId)
|
||||
return Result<TemplateFolder>.Failure("Cannot move a folder under one of its descendants (cycle).");
|
||||
if (++iterations > byId.Count)
|
||||
return Result<TemplateFolder>.Failure("Folder hierarchy contains a cycle; cannot determine ancestry.");
|
||||
cursor = byId.TryGetValue(cursor.Value, out var node) ? node.ParentFolderId : null;
|
||||
}
|
||||
|
||||
// Sibling-name uniqueness in destination.
|
||||
if (all.Any(f => f.Id != folderId
|
||||
&& f.ParentFolderId == newParentId
|
||||
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists in the target folder.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var all = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
if (all.Any(f => f.Id != folderId
|
||||
&& f.ParentFolderId == null
|
||||
&& string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return Result<TemplateFolder>.Failure($"A folder named '{folder.Name}' already exists at the root.");
|
||||
}
|
||||
|
||||
folder.ParentFolderId = newParentId;
|
||||
await _repository.UpdateFolderAsync(folder, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Move", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken);
|
||||
|
||||
return Result<TemplateFolder>.Success(folder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an empty template folder. Fails if the folder contains sub-folders or templates.
|
||||
/// </summary>
|
||||
/// <param name="folderId">Id of the folder to delete.</param>
|
||||
/// <param name="user">Username of the actor performing the operation (for audit).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<Result<bool>> DeleteFolderAsync(
|
||||
int folderId, string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken);
|
||||
if (folder == null)
|
||||
return Result<bool>.Failure($"Folder with ID {folderId} not found.");
|
||||
|
||||
var allFolders = await _repository.GetAllFoldersAsync(cancellationToken);
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
|
||||
var childFolderCount = allFolders.Count(f => f.ParentFolderId == folderId);
|
||||
var childTemplateCount = allTemplates.Count(t => t.FolderId == folderId);
|
||||
|
||||
if (childFolderCount > 0 || childTemplateCount > 0)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (childTemplateCount > 0)
|
||||
parts.Add($"{childTemplateCount} template{(childTemplateCount == 1 ? "" : "s")}");
|
||||
if (childFolderCount > 0)
|
||||
parts.Add($"{childFolderCount} subfolder{(childFolderCount == 1 ? "" : "s")}");
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete folder '{folder.Name}': it contains {string.Join(" and ", parts)}. " +
|
||||
"Move or delete contents first.");
|
||||
}
|
||||
|
||||
await _repository.DeleteFolderAsync(folderId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateFolder", folderId.ToString(), folder.Name, null, cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user