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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user