7b0b9c7365
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.
1216 lines
59 KiB
C#
1216 lines
59 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Core service for Template Engine operations.
|
|
/// Covers CRUD for templates and their members (attributes, alarms, scripts, compositions),
|
|
/// inheritance and composition rules, override/locking validation, collision detection, and acyclicity enforcement.
|
|
/// </summary>
|
|
public class TemplateService
|
|
{
|
|
private readonly ITemplateEngineRepository _repository;
|
|
private readonly IAuditService _auditService;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the TemplateService class.
|
|
/// </summary>
|
|
/// <param name="repository">Template engine repository for data access.</param>
|
|
/// <param name="auditService">Service for audit logging.</param>
|
|
public TemplateService(ITemplateEngineRepository repository, IAuditService auditService)
|
|
{
|
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
|
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-1: Template CRUD with Inheritance
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Creates a new template with optional inheritance and folder assignment.
|
|
/// </summary>
|
|
/// <param name="name">Name of the template.</param>
|
|
/// <param name="description">Optional description.</param>
|
|
/// <param name="parentTemplateId">Optional ID of the parent template for inheritance.</param>
|
|
/// <param name="user">Username of the user creating the template.</param>
|
|
/// <param name="folderId">Optional ID of the folder to place the template in.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the created template or failure message.</returns>
|
|
public async Task<Result<Template>> CreateTemplateAsync(
|
|
string name,
|
|
string? description,
|
|
int? parentTemplateId,
|
|
string user,
|
|
int? folderId = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return Result<Template>.Failure("Template name is required.");
|
|
|
|
// Validate parent exists if specified
|
|
if (parentTemplateId.HasValue)
|
|
{
|
|
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
|
|
if (parent == null)
|
|
return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
|
|
}
|
|
|
|
var template = new Template(name)
|
|
{
|
|
Description = description,
|
|
ParentTemplateId = parentTemplateId,
|
|
FolderId = folderId
|
|
};
|
|
|
|
// No collision or acyclicity check is needed here: a freshly created
|
|
// template has no members of its own, the parent (validated above to
|
|
// exist) was already collision-checked when its members were added,
|
|
// and a brand-new child cannot be an ancestor of its parent. Naming
|
|
// collisions are enforced on every member-mutating call (AddAttribute,
|
|
// AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.
|
|
|
|
// TemplateEngine-020: save the entity first so EF Core populates the
|
|
// auto-generated key, then write the audit row with the real
|
|
// template.Id, then save the audit row. The pre-fix order logged
|
|
// EntityId = "0" because the audit row was queued before
|
|
// SaveChangesAsync ran — every Create audit entry lost the link back
|
|
// to the row it described.
|
|
await _repository.AddTemplateAsync(template, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "Template", template.Id.ToString(), name, template, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<Template>.Success(template);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates a template's name and description. Parent template is immutable after creation.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template to update.</param>
|
|
/// <param name="name">New name for the template.</param>
|
|
/// <param name="description">New description.</param>
|
|
/// <param name="parentTemplateId">Must match the existing parent (cannot be changed).</param>
|
|
/// <param name="user">Username of the user updating the template.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the updated template or failure message.</returns>
|
|
public async Task<Result<Template>> UpdateTemplateAsync(
|
|
int templateId,
|
|
string name,
|
|
string? description,
|
|
int? parentTemplateId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return Result<Template>.Failure("Template name is required.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<Template>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
// ParentTemplateId is immutable after creation — set once at create time.
|
|
// Reject any attempt to change it (null→value, value→null, or value→other).
|
|
if (parentTemplateId != template.ParentTemplateId)
|
|
{
|
|
return Result<Template>.Failure(
|
|
"Parent template cannot be changed after creation.");
|
|
}
|
|
|
|
template.Name = name;
|
|
template.Description = description;
|
|
|
|
// Check for naming collisions after the change
|
|
var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
|
|
if (collisionResult != null)
|
|
return Result<Template>.Failure(collisionResult);
|
|
|
|
await _repository.UpdateTemplateAsync(template, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "Template", templateId.ToString(), name, template, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<Template>.Success(template);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a template if no instances depend on it.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template to delete.</param>
|
|
/// <param name="user">Username of the user deleting the template.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result indicating success or failure.</returns>
|
|
public async Task<Result<bool>> DeleteTemplateAsync(
|
|
int templateId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
// Deletion-constraint logic (instances / child / derived / composing
|
|
// templates) lives in exactly one place — TemplateDeletionService — so a
|
|
// future rule change cannot drift between two implementations
|
|
// (TemplateEngine-014). TemplateService owns only the audit-logging side
|
|
// effect, which the deletion service is unaware of.
|
|
var deletionService = new Services.TemplateDeletionService(_repository);
|
|
var deleteResult = await deletionService.DeleteTemplateAsync(templateId, cancellationToken);
|
|
if (deleteResult.IsFailure)
|
|
return deleteResult;
|
|
|
|
// TemplateDeletionService already persisted the delete; the audit entry
|
|
// is added to the change tracker here and needs its own SaveChangesAsync.
|
|
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves a template to a different folder.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template to move.</param>
|
|
/// <param name="newFolderId">ID of the target folder, or null to remove from folder.</param>
|
|
/// <param name="user">Username of the user moving the template.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the updated template or failure message.</returns>
|
|
public async Task<Result<Template>> MoveTemplateAsync(
|
|
int templateId,
|
|
int? newFolderId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<Template>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
if (newFolderId.HasValue)
|
|
{
|
|
var folder = await _repository.GetFolderByIdAsync(newFolderId.Value, cancellationToken);
|
|
if (folder == null)
|
|
return Result<Template>.Failure($"Target folder with ID {newFolderId.Value} not found.");
|
|
}
|
|
|
|
// No-op move — skip the collision check (a template moving to its own
|
|
// folder cannot collide with itself).
|
|
if (template.FolderId != newFolderId)
|
|
{
|
|
// Sibling-name uniqueness at the destination (TemplateEngine-021),
|
|
// mirroring TemplateFolderService.MoveFolderAsync. A template move
|
|
// changes only FolderId, so there is no inheritance- or
|
|
// composition-graph cycle to detect (templates have no folder-
|
|
// children navigation; ParentTemplateId is untouched here). The
|
|
// only invariant the move can break is two templates sharing a
|
|
// (FolderId, Name) at the destination, which the design's
|
|
// naming-collisions-are-design-time-errors rule forbids.
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var collision = allTemplates.FirstOrDefault(t =>
|
|
t.Id != templateId &&
|
|
t.FolderId == newFolderId &&
|
|
string.Equals(t.Name, template.Name, StringComparison.OrdinalIgnoreCase));
|
|
if (collision != null)
|
|
{
|
|
var location = newFolderId.HasValue ? "the target folder" : "the root";
|
|
return Result<Template>.Failure(
|
|
$"A template named '{template.Name}' already exists in {location}.");
|
|
}
|
|
}
|
|
|
|
template.FolderId = newFolderId;
|
|
await _repository.UpdateTemplateAsync(template, cancellationToken);
|
|
await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<Template>.Success(template);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a template by ID.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The template, or null if not found.</returns>
|
|
public async Task<Template?> GetTemplateByIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all templates.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>List of all templates.</returns>
|
|
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-2: Attribute Definitions with Lock Flags
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Adds an attribute definition to a template.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template.</param>
|
|
/// <param name="attribute">Attribute definition to add.</param>
|
|
/// <param name="user">Username of the user adding the attribute.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the added attribute or failure message.</returns>
|
|
public async Task<Result<TemplateAttribute>> AddAttributeAsync(
|
|
int templateId,
|
|
TemplateAttribute attribute,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateAttribute>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
// Check for duplicate name at this template level
|
|
if (template.Attributes.Any(a => a.Name == attribute.Name))
|
|
return Result<TemplateAttribute>.Failure(
|
|
$"Attribute '{attribute.Name}' already exists on template '{template.Name}'.");
|
|
|
|
attribute.TemplateId = templateId;
|
|
|
|
// If inheriting, validate not trying to add a member that would collide
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
|
|
|
|
// TemplateEngine-020: save-then-audit so the audit row carries the
|
|
// real attribute Id rather than a literal "0".
|
|
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAttribute>.Success(attribute);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an attribute definition with lock and inheritance validation.
|
|
/// </summary>
|
|
/// <param name="attributeId">ID of the attribute to update.</param>
|
|
/// <param name="proposed">New attribute definition values.</param>
|
|
/// <param name="user">Username of the user updating the attribute.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the updated attribute or failure message.</returns>
|
|
public async Task<Result<TemplateAttribute>> UpdateAttributeAsync(
|
|
int attributeId,
|
|
TemplateAttribute proposed,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existing = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
|
|
if (existing == null)
|
|
return Result<TemplateAttribute>.Failure($"Attribute with ID {attributeId} not found.");
|
|
|
|
// Validate override rules if this is an override (parent has same-named attribute)
|
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null && parentMember.IsLocked)
|
|
return Result<TemplateAttribute>.Failure(
|
|
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
|
|
|
|
// Derived templates may not override fields the base marked LockedInDerived.
|
|
if (template.IsDerived)
|
|
{
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
|
var baseAttr = baseTemplate?.Attributes.FirstOrDefault(a => a.Name == existing.Name);
|
|
if (baseAttr != null && baseAttr.LockedInDerived)
|
|
return Result<TemplateAttribute>.Failure(
|
|
$"Attribute '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
|
}
|
|
}
|
|
|
|
// Validate lock change rules
|
|
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
|
if (lockError != null)
|
|
return Result<TemplateAttribute>.Failure(lockError);
|
|
|
|
// LockedInDerived is a one-way ratchet on base templates: once a base
|
|
// marks an attribute LockedInDerived it cannot be cleared, otherwise
|
|
// derived overrides that were previously blocked would become
|
|
// retroactively legal (TemplateEngine-022). Only meaningful on base
|
|
// templates — derived rows never carry an authoritative LockedInDerived.
|
|
if (template?.IsDerived != true)
|
|
{
|
|
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
|
|
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
|
|
if (lockedInDerivedError != null)
|
|
return Result<TemplateAttribute>.Failure(lockedInDerivedError);
|
|
}
|
|
|
|
// Validate fixed-field granularity. DataType and DataSourceReference are
|
|
// fixed by the defining level for every attribute — locked or not — so
|
|
// the error is always honoured (a locked attribute is already rejected
|
|
// earlier inside the helper).
|
|
var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
|
|
if (granularityError != null)
|
|
return Result<TemplateAttribute>.Failure(granularityError);
|
|
|
|
// Apply overridable fields. DataType / DataSourceReference are fixed and
|
|
// are deliberately not copied from the proposed attribute.
|
|
existing.Value = proposed.Value;
|
|
existing.Description = proposed.Description;
|
|
existing.IsLocked = proposed.IsLocked;
|
|
if (template?.IsDerived == true)
|
|
existing.IsInherited = proposed.IsInherited;
|
|
else
|
|
existing.LockedInDerived = proposed.LockedInDerived;
|
|
|
|
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAttribute>.Success(existing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes an attribute definition from a template.
|
|
/// </summary>
|
|
/// <param name="attributeId">ID of the attribute to delete.</param>
|
|
/// <param name="user">Username of the user deleting the attribute.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result indicating success or failure.</returns>
|
|
public async Task<Result<bool>> DeleteAttributeAsync(
|
|
int attributeId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var attribute = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
|
|
if (attribute == null)
|
|
return Result<bool>.Failure($"Attribute with ID {attributeId} not found.");
|
|
|
|
// Cannot remove inherited parent members — only direct members
|
|
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
attribute.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null)
|
|
return Result<bool>.Failure(
|
|
$"Cannot remove attribute '{attribute.Name}': it is inherited from parent template.");
|
|
}
|
|
|
|
await _repository.DeleteTemplateAttributeAsync(attributeId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-3: Alarm Definitions
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Adds an alarm definition to a template.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template.</param>
|
|
/// <param name="alarm">Alarm definition to add.</param>
|
|
/// <param name="user">Username of the user adding the alarm.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the added alarm or failure message.</returns>
|
|
public async Task<Result<TemplateAlarm>> AddAlarmAsync(
|
|
int templateId,
|
|
TemplateAlarm alarm,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateAlarm>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
if (template.Alarms.Any(a => a.Name == alarm.Name))
|
|
return Result<TemplateAlarm>.Failure(
|
|
$"Alarm '{alarm.Name}' already exists on template '{template.Name}'.");
|
|
|
|
// Validate priority range
|
|
if (alarm.PriorityLevel < 0 || alarm.PriorityLevel > 1000)
|
|
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
|
|
|
|
alarm.TemplateId = templateId;
|
|
|
|
// Check collisions
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
|
|
|
|
// TemplateEngine-020: save-then-audit so the audit row carries the
|
|
// real alarm Id rather than a literal "0".
|
|
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAlarm>.Success(alarm);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an alarm definition with lock and inheritance validation.
|
|
/// </summary>
|
|
/// <param name="alarmId">ID of the alarm to update.</param>
|
|
/// <param name="proposed">New alarm definition values.</param>
|
|
/// <param name="user">Username of the user updating the alarm.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the updated alarm or failure message.</returns>
|
|
public async Task<Result<TemplateAlarm>> UpdateAlarmAsync(
|
|
int alarmId,
|
|
TemplateAlarm proposed,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existing = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
|
|
if (existing == null)
|
|
return Result<TemplateAlarm>.Failure($"Alarm with ID {alarmId} not found.");
|
|
|
|
// Validate priority range
|
|
if (proposed.PriorityLevel < 0 || proposed.PriorityLevel > 1000)
|
|
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
|
|
|
|
// Validate lock change
|
|
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
|
if (lockError != null)
|
|
return Result<TemplateAlarm>.Failure(lockError);
|
|
|
|
// Check parent lock
|
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null && parentMember.IsLocked)
|
|
return Result<TemplateAlarm>.Failure(
|
|
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
|
|
|
|
// Derived templates may not override alarms the base marked LockedInDerived.
|
|
if (template.IsDerived)
|
|
{
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
|
var baseAlarm = baseTemplate?.Alarms.FirstOrDefault(a => a.Name == existing.Name);
|
|
if (baseAlarm != null && baseAlarm.LockedInDerived)
|
|
return Result<TemplateAlarm>.Failure(
|
|
$"Alarm '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
|
}
|
|
}
|
|
|
|
// One-way LockedInDerived ratchet on base templates (TemplateEngine-022).
|
|
if (template?.IsDerived != true)
|
|
{
|
|
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
|
|
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
|
|
if (lockedInDerivedError != null)
|
|
return Result<TemplateAlarm>.Failure(lockedInDerivedError);
|
|
}
|
|
|
|
// Validate fixed fields
|
|
var overrideError = LockEnforcer.ValidateAlarmOverride(existing, proposed);
|
|
if (overrideError != null)
|
|
return Result<TemplateAlarm>.Failure(overrideError);
|
|
|
|
// Apply overridable fields
|
|
existing.PriorityLevel = proposed.PriorityLevel;
|
|
existing.TriggerConfiguration = proposed.TriggerConfiguration;
|
|
existing.Description = proposed.Description;
|
|
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
|
|
existing.IsLocked = proposed.IsLocked;
|
|
if (template?.IsDerived == true)
|
|
existing.IsInherited = proposed.IsInherited;
|
|
else
|
|
existing.LockedInDerived = proposed.LockedInDerived;
|
|
// Name and TriggerType are NOT updated (fixed)
|
|
|
|
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateAlarm>.Success(existing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes an alarm definition from a template.
|
|
/// </summary>
|
|
/// <param name="alarmId">ID of the alarm to delete.</param>
|
|
/// <param name="user">Username of the user deleting the alarm.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result indicating success or failure.</returns>
|
|
public async Task<Result<bool>> DeleteAlarmAsync(
|
|
int alarmId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var alarm = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
|
|
if (alarm == null)
|
|
return Result<bool>.Failure($"Alarm with ID {alarmId} not found.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
alarm.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null)
|
|
return Result<bool>.Failure(
|
|
$"Cannot remove alarm '{alarm.Name}': it is inherited from parent template.");
|
|
}
|
|
|
|
await _repository.DeleteTemplateAlarmAsync(alarmId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-4: Script Definitions
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Adds a script definition to a template.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template.</param>
|
|
/// <param name="script">Script definition to add.</param>
|
|
/// <param name="user">Username of the user adding the script.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the added script or failure message.</returns>
|
|
public async Task<Result<TemplateScript>> AddScriptAsync(
|
|
int templateId,
|
|
TemplateScript script,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateScript>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
if (template.Scripts.Any(s => s.Name == script.Name))
|
|
return Result<TemplateScript>.Failure(
|
|
$"Script '{script.Name}' already exists on template '{template.Name}'.");
|
|
|
|
script.TemplateId = templateId;
|
|
|
|
// Check collisions
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var testTemplate = CloneTemplateWithNewScript(template, script);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
|
|
|
|
// TemplateEngine-020: save-then-audit so the audit row carries the
|
|
// real script Id rather than a literal "0".
|
|
await _repository.AddTemplateScriptAsync(script, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateScript>.Success(script);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates a script definition with lock and inheritance validation.
|
|
/// </summary>
|
|
/// <param name="scriptId">ID of the script to update.</param>
|
|
/// <param name="proposed">New script definition values.</param>
|
|
/// <param name="user">Username of the user updating the script.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the updated script or failure message.</returns>
|
|
public async Task<Result<TemplateScript>> UpdateScriptAsync(
|
|
int scriptId,
|
|
TemplateScript proposed,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var existing = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
|
|
if (existing == null)
|
|
return Result<TemplateScript>.Failure($"Script with ID {scriptId} not found.");
|
|
|
|
// Validate lock change
|
|
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
|
|
if (lockError != null)
|
|
return Result<TemplateScript>.Failure(lockError);
|
|
|
|
// Check parent lock; the LockedInDerived ratchet is enforced after we
|
|
// know whether the owning template is derived.
|
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null && parentMember.IsLocked)
|
|
return Result<TemplateScript>.Failure(
|
|
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
|
|
|
|
// Derived templates may not override scripts the base marked LockedInDerived.
|
|
if (template.IsDerived)
|
|
{
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
|
var baseScript = baseTemplate?.Scripts.FirstOrDefault(s => s.Name == existing.Name);
|
|
if (baseScript != null && baseScript.LockedInDerived)
|
|
return Result<TemplateScript>.Failure(
|
|
$"Script '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
|
}
|
|
}
|
|
|
|
// One-way LockedInDerived ratchet on base templates (TemplateEngine-022).
|
|
if (template?.IsDerived != true)
|
|
{
|
|
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
|
|
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
|
|
if (lockedInDerivedError != null)
|
|
return Result<TemplateScript>.Failure(lockedInDerivedError);
|
|
}
|
|
|
|
// Validate fixed fields
|
|
var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
|
|
if (overrideError != null)
|
|
return Result<TemplateScript>.Failure(overrideError);
|
|
|
|
// Apply overridable fields
|
|
existing.Code = proposed.Code;
|
|
existing.TriggerType = proposed.TriggerType;
|
|
existing.TriggerConfiguration = proposed.TriggerConfiguration;
|
|
existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
|
|
existing.ParameterDefinitions = proposed.ParameterDefinitions;
|
|
existing.ReturnDefinition = proposed.ReturnDefinition;
|
|
existing.IsLocked = proposed.IsLocked;
|
|
if (template?.IsDerived == true)
|
|
existing.IsInherited = proposed.IsInherited;
|
|
else
|
|
existing.LockedInDerived = proposed.LockedInDerived;
|
|
// Name is NOT updated (fixed)
|
|
|
|
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateScript>.Success(existing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a script definition from a template.
|
|
/// </summary>
|
|
/// <param name="scriptId">ID of the script to delete.</param>
|
|
/// <param name="user">Username of the user deleting the script.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result indicating success or failure.</returns>
|
|
public async Task<Result<bool>> DeleteScriptAsync(
|
|
int scriptId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var script = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
|
|
if (script == null)
|
|
return Result<bool>.Failure($"Script with ID {scriptId} not found.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
|
|
if (template?.ParentTemplateId != null)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
|
script.Name, template.ParentTemplateId.Value, allTemplates);
|
|
if (parentMember != null)
|
|
return Result<bool>.Failure(
|
|
$"Cannot remove script '{script.Name}': it is inherited from parent template.");
|
|
}
|
|
|
|
await _repository.DeleteTemplateScriptAsync(scriptId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-6: Composition with Recursive Nesting
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Adds a composition (template slot) to a template with acyclicity and collision validation.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the parent template.</param>
|
|
/// <param name="composedTemplateId">ID of the template to compose.</param>
|
|
/// <param name="instanceName">Name for the composition slot.</param>
|
|
/// <param name="user">Username of the user adding the composition.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the added composition or failure message.</returns>
|
|
public async Task<Result<TemplateComposition>> AddCompositionAsync(
|
|
int templateId,
|
|
int composedTemplateId,
|
|
string instanceName,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(instanceName))
|
|
return Result<TemplateComposition>.Failure("Instance name is required for composition.");
|
|
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
var baseTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
|
|
if (baseTemplate == null)
|
|
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
|
|
|
|
// Only base templates can be composed; derived templates are slot-owned.
|
|
if (baseTemplate.IsDerived)
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Cannot compose template '{baseTemplate.Name}': it is a derived template. Compose its base instead.");
|
|
|
|
// Check for duplicate instance name
|
|
if (template.Compositions.Any(c => c.InstanceName == instanceName))
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
|
|
|
|
// Acyclicity is checked against the base, not the to-be-created derived template —
|
|
// the derived inherits from the base, so a base→base cycle is the meaningful check.
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
|
|
if (cycleError != null)
|
|
return Result<TemplateComposition>.Failure(cycleError);
|
|
|
|
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
|
|
if (crossCycleError != null)
|
|
return Result<TemplateComposition>.Failure(crossCycleError);
|
|
|
|
var probeComposition = new TemplateComposition(instanceName)
|
|
{
|
|
TemplateId = templateId,
|
|
ComposedTemplateId = composedTemplateId
|
|
};
|
|
|
|
var testTemplate = CloneTemplateWithNewComposition(template, probeComposition);
|
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
|
|
|
// No global name pre-check: derived templates store their contained
|
|
// (slot) name, which need only be unique within the owner — and that is
|
|
// already enforced above and by the (TemplateId, InstanceName) index.
|
|
// TemplateEngine-020: CreateCascadedCompositionAsync already saves the
|
|
// composition row, so composition.Id is populated by the time control
|
|
// returns here — the audit row therefore carries the real Id instead
|
|
// of the pre-fix literal "0".
|
|
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
|
await _auditService.LogAsync(user, "Create", "TemplateComposition", composition.Id.ToString(), instanceName, composition, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateComposition>.Success(composition);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a derived template under <paramref name="outerTemplate"/> that
|
|
/// wraps <paramref name="source"/>, then recursively cascades the source's
|
|
/// own compositions so the slot graph is replicated under the new derived.
|
|
/// Used both for the user-initiated top-level compose and the recursive
|
|
/// children — neither path re-validates (caller pre-flights).
|
|
/// </summary>
|
|
private async Task<TemplateComposition> CreateCascadedCompositionAsync(
|
|
Template outerTemplate,
|
|
Template source,
|
|
string instanceName,
|
|
string user,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// A derived template stores its contained name — the slot's instance
|
|
// name, unique within its owner. The qualified path is computed on read
|
|
// (see TemplateNaming.QualifiedName).
|
|
var derived = BuildDerivedTemplate(source, instanceName);
|
|
|
|
await _repository.AddTemplateAsync(derived, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
var composition = new TemplateComposition(instanceName)
|
|
{
|
|
TemplateId = outerTemplate.Id,
|
|
ComposedTemplateId = derived.Id
|
|
};
|
|
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
derived.OwnerCompositionId = composition.Id;
|
|
await _repository.UpdateTemplateAsync(derived, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
// Cascade — replicate each of the source's compositions onto the new
|
|
// derived. The child's ParentTemplateId points at the source-side
|
|
// child (so override chains stay intact across nesting).
|
|
foreach (var childComp in source.Compositions.ToList())
|
|
{
|
|
var childSource = await _repository.GetTemplateByIdAsync(childComp.ComposedTemplateId, cancellationToken);
|
|
if (childSource == null) continue;
|
|
await CreateCascadedCompositionAsync(derived, childSource, childComp.InstanceName, user, cancellationToken);
|
|
}
|
|
|
|
return composition;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renames a composition slot while preserving the composed template and nested slots.
|
|
/// </summary>
|
|
/// <param name="compositionId">ID of the composition to rename.</param>
|
|
/// <param name="newInstanceName">New slot name.</param>
|
|
/// <param name="user">Username of the user renaming the composition.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result containing the updated composition or failure message.</returns>
|
|
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
|
|
int compositionId,
|
|
string newInstanceName,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newInstanceName))
|
|
return Result<TemplateComposition>.Failure("Slot name is required.");
|
|
|
|
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
|
|
if (composition == null)
|
|
return Result<TemplateComposition>.Failure($"Composition with ID {compositionId} not found.");
|
|
|
|
if (composition.InstanceName == newInstanceName) return Result<TemplateComposition>.Success(composition);
|
|
|
|
var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken);
|
|
if (owner == null)
|
|
return Result<TemplateComposition>.Failure($"Owning template with ID {composition.TemplateId} not found.");
|
|
|
|
if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName))
|
|
return Result<TemplateComposition>.Failure(
|
|
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
|
|
|
|
// The derived template stores the slot's contained name, so renaming
|
|
// the slot renames just that one template. Nested derived templates
|
|
// keep their own contained names — an ancestor slot rename never
|
|
// touches them, and the qualified path is recomputed on read.
|
|
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
|
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
|
|
{
|
|
derived.Name = newInstanceName;
|
|
await _repository.UpdateTemplateAsync(derived, cancellationToken);
|
|
}
|
|
|
|
composition.InstanceName = newInstanceName;
|
|
await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken);
|
|
await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken);
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<TemplateComposition>.Success(composition);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a composition (template slot) and cascades deletion of slot-owned derived templates.
|
|
/// </summary>
|
|
/// <param name="compositionId">ID of the composition to delete.</param>
|
|
/// <param name="user">Username of the user deleting the composition.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result indicating success or failure.</returns>
|
|
public async Task<Result<bool>> DeleteCompositionAsync(
|
|
int compositionId,
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
|
|
if (composition == null)
|
|
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
|
|
|
|
// Identify the slot-owned derived template (post Phase-3 migration this is the
|
|
// typical case; pre-migration the composition may still point at a base).
|
|
var composedTemplate = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
|
|
|
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
|
|
|
|
if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
|
|
{
|
|
await CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken);
|
|
}
|
|
|
|
await _repository.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively deletes a derived template along with the cascade of inner
|
|
/// derived templates the compose flow created. Each composition row on the
|
|
/// derived has its slot-owned child template removed first, then the row,
|
|
/// then the derived itself.
|
|
/// </summary>
|
|
private async Task CascadeDeleteDerivedAsync(Template derived, string user, CancellationToken cancellationToken)
|
|
{
|
|
foreach (var child in derived.Compositions.ToList())
|
|
{
|
|
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
|
|
await _repository.DeleteTemplateCompositionAsync(child.Id, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "TemplateComposition", child.Id.ToString(), child.InstanceName, null, cancellationToken);
|
|
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
|
|
await CascadeDeleteDerivedAsync(childDerived, user, cancellationToken);
|
|
}
|
|
|
|
await _repository.DeleteTemplateAsync(derived.Id, cancellationToken);
|
|
await _auditService.LogAsync(user, "Delete", "Template", derived.Id.ToString(), derived.Name, null, cancellationToken);
|
|
}
|
|
|
|
private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
|
|
{
|
|
var derived = new Template(derivedName)
|
|
{
|
|
Description = baseTemplate.Description,
|
|
ParentTemplateId = baseTemplate.Id,
|
|
IsDerived = true,
|
|
};
|
|
|
|
foreach (var attr in baseTemplate.Attributes)
|
|
{
|
|
derived.Attributes.Add(new TemplateAttribute(attr.Name)
|
|
{
|
|
Value = attr.Value,
|
|
DataType = attr.DataType,
|
|
IsLocked = attr.IsLocked,
|
|
Description = attr.Description,
|
|
DataSourceReference = attr.DataSourceReference,
|
|
IsInherited = true,
|
|
LockedInDerived = false,
|
|
});
|
|
}
|
|
|
|
foreach (var alarm in baseTemplate.Alarms)
|
|
{
|
|
derived.Alarms.Add(new TemplateAlarm(alarm.Name)
|
|
{
|
|
Description = alarm.Description,
|
|
PriorityLevel = alarm.PriorityLevel,
|
|
IsLocked = alarm.IsLocked,
|
|
TriggerType = alarm.TriggerType,
|
|
TriggerConfiguration = alarm.TriggerConfiguration,
|
|
OnTriggerScriptId = alarm.OnTriggerScriptId,
|
|
IsInherited = true,
|
|
LockedInDerived = false,
|
|
});
|
|
}
|
|
|
|
foreach (var script in baseTemplate.Scripts)
|
|
{
|
|
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
|
|
{
|
|
IsLocked = script.IsLocked,
|
|
TriggerType = script.TriggerType,
|
|
TriggerConfiguration = script.TriggerConfiguration,
|
|
ParameterDefinitions = script.ParameterDefinitions,
|
|
ReturnDefinition = script.ReturnDefinition,
|
|
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
|
IsInherited = true,
|
|
LockedInDerived = false,
|
|
});
|
|
}
|
|
|
|
return derived;
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Resolves all effective members for a template using canonical (path-qualified) names.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template to resolve.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>List of resolved template members with canonical names.</returns>
|
|
public async Task<IReadOnlyList<TemplateResolver.ResolvedTemplateMember>> ResolveTemplateMembersAsync(
|
|
int templateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-8/9/10/11: Override validation (integrated into Update* methods above)
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Validates whether overriding a member by canonical name is allowed.
|
|
/// Used for composition overrides (WP-11) and inheritance overrides (WP-10).
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template.</param>
|
|
/// <param name="canonicalName">Canonical name of the member to validate.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result indicating whether override is allowed.</returns>
|
|
public async Task<Result<bool>> ValidateOverrideAsync(
|
|
int templateId,
|
|
string canonicalName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
|
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
|
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
|
|
|
|
if (member == null)
|
|
return Result<bool>.Failure($"No member found with canonical name '{canonicalName}'.");
|
|
|
|
if (member.IsLocked)
|
|
return Result<bool>.Failure($"Member '{canonicalName}' is locked and cannot be overridden.");
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-12: Naming Collision Detection
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Checks a template for naming collisions across all its members.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template to check.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>List of collision error messages, or empty if none found.</returns>
|
|
public async Task<IReadOnlyList<string>> DetectCollisionsAsync(
|
|
int templateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
|
|
if (template == null)
|
|
return new[] { $"Template with ID {templateId} not found." };
|
|
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
return CollisionDetector.DetectCollisions(template, allTemplates);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-13: Graph Acyclicity
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Validates that a proposed inheritance or composition change does not create a cycle.
|
|
/// </summary>
|
|
/// <param name="templateId">ID of the template being modified.</param>
|
|
/// <param name="proposedParentId">Proposed parent template ID for inheritance.</param>
|
|
/// <param name="proposedComposedTemplateId">Proposed composed template ID for composition.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>Result indicating whether the change would create a cycle.</returns>
|
|
public async Task<Result<bool>> ValidateAcyclicityAsync(
|
|
int templateId,
|
|
int? proposedParentId,
|
|
int? proposedComposedTemplateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
|
|
if (proposedParentId.HasValue)
|
|
{
|
|
var inheritanceCycle = CycleDetector.DetectInheritanceCycle(templateId, proposedParentId.Value, allTemplates);
|
|
if (inheritanceCycle != null)
|
|
return Result<bool>.Failure(inheritanceCycle);
|
|
}
|
|
|
|
if (proposedComposedTemplateId.HasValue)
|
|
{
|
|
var compositionCycle = CycleDetector.DetectCompositionCycle(templateId, proposedComposedTemplateId.Value, allTemplates);
|
|
if (compositionCycle != null)
|
|
return Result<bool>.Failure(compositionCycle);
|
|
}
|
|
|
|
var crossCycle = CycleDetector.DetectCrossGraphCycle(templateId, proposedParentId, proposedComposedTemplateId, allTemplates);
|
|
if (crossCycle != null)
|
|
return Result<bool>.Failure(crossCycle);
|
|
|
|
return Result<bool>.Success(true);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Helper methods
|
|
// ========================================================================
|
|
|
|
private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
|
|
{
|
|
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
|
|
if (collisions.Count > 0)
|
|
return string.Join(" ", collisions);
|
|
return null;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewAttribute(Template original, TemplateAttribute newAttr)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
clone.Attributes.Add(newAttr);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
return clone;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewAlarm(Template original, TemplateAlarm newAlarm)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
clone.Alarms.Add(newAlarm);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
return clone;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewScript(Template original, TemplateScript newScript)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
clone.Scripts.Add(newScript);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
return clone;
|
|
}
|
|
|
|
private static Template CloneTemplateWithNewComposition(Template original, TemplateComposition newComp)
|
|
{
|
|
var clone = new Template(original.Name)
|
|
{
|
|
Id = original.Id,
|
|
ParentTemplateId = original.ParentTemplateId,
|
|
Description = original.Description,
|
|
};
|
|
foreach (var a in original.Attributes) clone.Attributes.Add(a);
|
|
foreach (var a in original.Alarms) clone.Alarms.Add(a);
|
|
foreach (var s in original.Scripts) clone.Scripts.Add(s);
|
|
foreach (var c in original.Compositions) clone.Compositions.Add(c);
|
|
clone.Compositions.Add(newComp);
|
|
return clone;
|
|
}
|
|
}
|