feat(adminui): CreateAliasTagAsync/UpdateAliasTagAsync + Galaxy-gateway guard

This commit is contained in:
Joseph Doherty
2026-06-11 21:17:45 -04:00
parent fcc73ccd2d
commit 53116bdd83
3 changed files with 386 additions and 0 deletions
@@ -379,6 +379,33 @@ public interface IUnsTreeService
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new Galaxy alias tag on an equipment: an ordinary equipment-bound <c>Tag</c> bound to a
/// <c>GalaxyMxGateway</c> driver, with <c>FolderPath</c> null and a <c>{"FullName":…}</c> TagConfig
/// carrying the picked Galaxy reference. Fails on a duplicate <c>TagId</c>, an empty Galaxy reference,
/// an unknown equipment, a driver that is not a Galaxy gateway in the equipment's cluster, or a name
/// already used on the equipment.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable alias fields (id, name, gateway, type, access, reference).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures.</returns>
Task<UnsMutationResult> CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default);
/// <summary>
/// Updates a Galaxy alias tag's gateway binding, name, data type, access level, and Galaxy reference
/// (the <c>{"FullName":…}</c> TagConfig). The owning <c>EquipmentId</c> and the null <c>FolderPath</c>
/// are preserved. Re-runs the empty-reference and Galaxy-gateway-in-cluster guards against the alias's
/// existing equipment, and enforces name uniqueness on that equipment excluding this tag. Uses
/// last-write-wins optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The alias tag to update.</param>
/// <param name="input">The new operator-editable alias fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
@@ -893,6 +893,101 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateAliasTagAsync(
string equipmentId,
AliasTagInput input,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
{
return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
}
if (string.IsNullOrWhiteSpace(input.FullName))
{
return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
}
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
{
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
}
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
db.Tags.Add(BuildAliasTag(equipmentId, input));
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateAliasTagAsync(
string tagId,
AliasTagInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
if (string.IsNullOrWhiteSpace(input.FullName))
{
return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
}
var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(
t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
// EquipmentId and FolderPath (null) are preserved — alias tags are always equipment-bound.
entity.DriverInstanceId = input.DriverInstanceId;
entity.Name = input.Name;
entity.DataType = input.DataType;
entity.AccessLevel = input.AccessLevel;
entity.FolderPath = null;
entity.TagConfig = BuildAliasTagConfig(input.FullName);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this tag while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteTagAsync(
string tagId,
@@ -1290,6 +1385,65 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return null;
}
/// <summary>
/// Galaxy-aware driver guard for aliases: the driver must be a Galaxy gateway
/// (<c>DriverType == "GalaxyMxGateway"</c>) in the equipment's cluster. Distinct from
/// <see cref="CheckTagDriverGuardAsync"/>, which requires an Equipment-kind namespace and would reject
/// the gateway. Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
/// </summary>
private static async Task<UnsMutationResult?> CheckAliasDriverGuardAsync(
OtOpcUaConfigDbContext db,
string driverInstanceId,
string? equipmentCluster,
CancellationToken ct)
{
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (driver is null)
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
}
if (driver.DriverType != "GalaxyMxGateway")
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway.");
}
if (driver.ClusterId != equipmentCluster)
{
return new UnsMutationResult(
false,
$"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'.");
}
return null;
}
/// <summary>
/// Serialises a Galaxy reference into the alias TagConfig envelope <c>{"FullName":"&lt;ref&gt;"}</c>.
/// </summary>
private static string BuildAliasTagConfig(string fullName) =>
System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string> { ["FullName"] = fullName });
/// <summary>
/// Builds the alias <see cref="Tag"/> entity for an equipment: a Galaxy-gateway-bound, equipment-scoped
/// tag with a null <c>FolderPath</c>, no poll group, non-idempotent writes, and the
/// <c>{"FullName":…}</c> TagConfig. A pure builder — reused by the relay→alias converter so create and
/// conversion produce byte-identical rows.
/// </summary>
private static Tag BuildAliasTag(string equipmentId, AliasTagInput input) => new()
{
TagId = input.TagId,
DriverInstanceId = input.DriverInstanceId,
EquipmentId = equipmentId,
Name = input.Name,
FolderPath = null,
DataType = input.DataType,
AccessLevel = input.AccessLevel,
WriteIdempotent = false,
PollGroupId = null,
TagConfig = BuildAliasTagConfig(input.FullName),
};
/// <summary>
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
/// Policy: