diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs index 96448775..e424c60e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs @@ -22,6 +22,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; /// Requested page size. The actor clamps this to the [1, 200] range, so /// the effective ceiling is 200 rows per page regardless of the value sent. /// +/// +/// Exact-match on one operation (the ?correlationId= deep link from the +/// Flow Debugger / Audit Log); null = no filter. Additive, trailing +/// optional so existing positional callers are unaffected. +/// public sealed record SiteCallQueryRequest( string CorrelationId, string? StatusFilter, @@ -34,7 +39,8 @@ public sealed record SiteCallQueryRequest( DateTime? AfterCreatedAtUtc, Guid? AfterId, int PageSize, - string? SourceNodeFilter = null); + string? SourceNodeFilter = null, + Guid? TrackedOperationIdFilter = null); /// /// A single SiteCalls row summarised for the Site Calls UI grid. Carries diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs index 82d37a57..3258b929 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/SiteCallQueryFilter.cs @@ -31,6 +31,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; /// "site-plant-a-node-a"). Exact match; null means "do not /// constrain". Rows with NULL SourceNode are excluded when set. /// +/// +/// Restrict to exactly one cached call by its primary key — the +/// ?correlationId= deep link from the Flow Debugger / Audit Log. +/// null means "do not constrain". AND-combined with every other field, +/// so a mismatching (etc.) still yields no rows. +/// public sealed record SiteCallQueryFilter( string? Channel = null, string? SourceSite = null, @@ -39,4 +45,5 @@ public sealed record SiteCallQueryFilter( DateTime? FromUtc = null, DateTime? ToUtc = null, DateTime? StuckCutoffUtc = null, - string? SourceNode = null); + string? SourceNode = null, + Guid? TrackedOperationId = null); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index de37235c..19d9f1ea 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -294,6 +294,12 @@ WHERE TrackedOperationId = @Id var fromUtc = filter.FromUtc; var toUtc = filter.ToUtc; var stuckCutoff = filter.StuckCutoffUtc; + // TrackedOperationId is a varchar(36) column holding the "D"-format GUID + // string (see SiteCallEntityTypeConfiguration), so the exact-match filter + // is compared as a string — same shape as the @AfterId cursor below. A + // uniqueidentifier parameter would make SQL Server convert the COLUMN side + // (higher type precedence) and lose the PK seek. + var trackedOperationIdString = filter.TrackedOperationId?.ToString("D"); // The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff) // is pushed into SQL here — both columns are plain (no value converter) @@ -315,6 +321,7 @@ WHERE (@Channel IS NULL OR Channel = @Channel) AND (@SourceNode IS NULL OR SourceNode = @SourceNode) AND (@Status IS NULL OR Status = @Status) AND (@Target IS NULL OR Target = @Target) + AND (@TrackedOperationId IS NULL OR TrackedOperationId = @TrackedOperationId) AND (@FromUtc IS NULL OR CreatedAtUtc >= @FromUtc) AND (@ToUtc IS NULL OR CreatedAtUtc <= @ToUtc) AND (@StuckCutoff IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < @StuckCutoff)) @@ -338,6 +345,7 @@ OPTION (RECOMPILE);"; Param("@SourceNode", SqlDbType.VarChar, filter.SourceNode), Param("@Status", SqlDbType.VarChar, filter.Status), Param("@Target", SqlDbType.VarChar, filter.Target), + Param("@TrackedOperationId", SqlDbType.VarChar, trackedOperationIdString), Param("@FromUtc", SqlDbType.DateTime2, fromUtc), Param("@ToUtc", SqlDbType.DateTime2, toUtc), Param("@StuckCutoff", SqlDbType.DateTime2, stuckCutoff), diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs index a8970ebf..46d88b91 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs @@ -832,7 +832,8 @@ public class SiteCallAuditActor : ReceiveActor // keyset cursor, so the page is always honest (full pages, no empty // pages with a non-null next cursor). StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null, - SourceNode: NullIfBlank(request.SourceNodeFilter)); + SourceNode: NullIfBlank(request.SourceNodeFilter), + TrackedOperationId: request.TrackedOperationIdFilter); var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize); var paging = new SiteCallPaging( diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs index f1353bb6..f389bbec 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -438,6 +438,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite)); } + [SkippableFact] + public async Task QueryAsync_FilterByTrackedOperationId_ReturnsExactlyThatRow() + { + // Flow Debugger follow-ups Task 2: SiteCallQueryFilter.TrackedOperationId is + // an exact PK match (the ?correlationId= deep link). The column is varchar(36) + // holding the "D"-format GUID string, so the predicate must compare as a + // string — a uniqueidentifier parameter would convert the column side and + // defeat the PK seek. + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var site = NewSiteId(); + await using var context = CreateContext(); + var repo = new SiteCallAuditRepository(context); + + var idA = TrackedOperationId.New(); + var idB = TrackedOperationId.New(); + var t0 = new DateTime(2026, 9, 6, 10, 0, 0, DateTimeKind.Utc); + await repo.UpsertAsync(NewRow(idA, sourceSite: site, createdAtUtc: t0)); + await repo.UpsertAsync(NewRow(idB, sourceSite: site, createdAtUtc: t0.AddMinutes(1))); + + var rows = await repo.QueryAsync( + new SiteCallQueryFilter(TrackedOperationId: idA.Value), + new SiteCallPaging(PageSize: 10)); + + var row = Assert.Single(rows); + Assert.Equal(idA, row.TrackedOperationId); + + // Composes with the other predicates: a mismatching site yields nothing. + var none = await repo.QueryAsync( + new SiteCallQueryFilter(SourceSite: NewSiteId(), TrackedOperationId: idA.Value), + new SiteCallPaging(PageSize: 10)); + Assert.Empty(none); + } + [SkippableFact] public async Task QueryAsync_KeysetPaging_NoOverlap() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs index e08ef198..c001ebf9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs @@ -268,6 +268,45 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture(TimeSpan.FromSeconds(10)); + Assert.True(response.Success); + Assert.Single(response.SiteCalls); + Assert.Equal(rowA.TrackedOperationId.Value, response.SiteCalls[0].TrackedOperationId); + Assert.Equal("Attempted", response.SiteCalls[0].Status); + // No further page — the exact-match filter yields at most one row. + Assert.Equal(rowA.TrackedOperationId.Value, response.NextAfterId); + } + [SkippableFact] public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor() {