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:
+524
@@ -0,0 +1,524 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the central Site Calls report page (Site Call Audit #22). A
|
||||
/// near-mirror of <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport"/>:
|
||||
/// it queries the central <c>SiteCalls</c> table via
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService.QuerySiteCallsAsync"/>,
|
||||
/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard
|
||||
/// of <c>Parked</c> cached calls to their owning site.
|
||||
///
|
||||
/// <para>
|
||||
/// Unlike the Notification report, the query response uses a <c>(CreatedAtUtc DESC,
|
||||
/// TrackedOperationId DESC)</c> keyset cursor rather than page numbers, so paging
|
||||
/// keeps a stack of the cursors that opened each page (to step backwards) plus the
|
||||
/// response's <c>NextAfter*</c> cursor (to step forwards).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retry/Discard relay to the owning site has a distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
|
||||
/// outcome — central is an eventually-consistent mirror, not the source of truth, so
|
||||
/// a relay that never reaches the site is a transient transport condition, surfaced
|
||||
/// to the operator differently from a generic failure.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
|
||||
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
|
||||
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
|
||||
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
|
||||
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
|
||||
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
|
||||
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class SiteCallsReport
|
||||
{
|
||||
private const int PageSize = 50;
|
||||
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
[Inject] private SiteScopeService SiteScope { get; set; } = null!;
|
||||
|
||||
// The Status filter <select> options — the exact strings the dropdown binds and
|
||||
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
|
||||
// filter when it matches one of these (case-insensitively); anything else is
|
||||
// dropped so a hand-crafted bad URL still renders the page unfiltered.
|
||||
private static readonly string[] ValidStatuses =
|
||||
{
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
|
||||
};
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private List<Site> _sites = new();
|
||||
// CentralUI-028: unfiltered site list so a permitted-site lookup resolves
|
||||
// correctly for a SourceSite whose Site was filtered out of the dropdown.
|
||||
private List<Site> _allSites = new();
|
||||
private bool _siteScopeSystemWide;
|
||||
private HashSet<int> _permittedSiteIds = new();
|
||||
|
||||
// List
|
||||
private List<SiteCallSummary>? _siteCalls;
|
||||
private bool _loading;
|
||||
private string? _listError;
|
||||
private bool _actionInProgress;
|
||||
|
||||
// Keyset paging. The first page is opened with the empty (null, null) cursor.
|
||||
// _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty
|
||||
// on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor
|
||||
// is the cursor for the following page, echoed back by the last query.
|
||||
private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new();
|
||||
private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
|
||||
private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;
|
||||
|
||||
// Row detail modal
|
||||
private SiteCallSummary? _detailSiteCall;
|
||||
private SiteCallDetail? _detail;
|
||||
private bool _detailLoading;
|
||||
private string? _detailError;
|
||||
|
||||
// Filters
|
||||
private string _statusFilter = string.Empty;
|
||||
private string _channelFilter = string.Empty;
|
||||
private string _siteFilter = string.Empty;
|
||||
private string _targetFilter = string.Empty;
|
||||
private string _nodeFilter = string.Empty;
|
||||
private bool _stuckOnly;
|
||||
private DateTime? _fromFilter;
|
||||
private DateTime? _toFilter;
|
||||
|
||||
private bool HasNextPage => _nextCursor is not null;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
// CentralUI-028: restrict the source-site dropdown to the user's
|
||||
// permitted set. System-wide users see the full list back unchanged.
|
||||
_sites = await SiteScope.FilterSitesAsync(_allSites);
|
||||
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
|
||||
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal — the source-site filter just falls back to raw site IDs.
|
||||
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
|
||||
}
|
||||
|
||||
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
|
||||
// grid load is already filtered (and the filter card controls reflect it).
|
||||
ApplyQueryStringFilters();
|
||||
|
||||
await RefreshAll();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
|
||||
/// string. <c>?status=<status></c> seeds <see cref="_statusFilter"/> when it
|
||||
/// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
|
||||
/// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
|
||||
/// is silently dropped, leaving the filter empty (the no-param behaviour).
|
||||
/// </summary>
|
||||
private void ApplyQueryStringFilters()
|
||||
{
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
|
||||
if (query.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.TryGetValue("status", out var statusValues))
|
||||
{
|
||||
var v = statusValues.ToString();
|
||||
// Round-trip the dropdown's own option strings (the KPI tile emits the
|
||||
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
|
||||
// <select> binds. An unrecognised value leaves the filter unset.
|
||||
var match = ValidStatuses.FirstOrDefault(
|
||||
s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (match is not null)
|
||||
{
|
||||
_statusFilter = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.TryGetValue("stuck", out var stuckValues)
|
||||
&& bool.TryParse(stuckValues.ToString(), out var stuck))
|
||||
{
|
||||
_stuckOnly = stuck;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
|
||||
private async Task RefreshAll()
|
||||
{
|
||||
await FetchPage(_currentCursor);
|
||||
}
|
||||
|
||||
/// <summary>Apply the filters and start again from the first page.</summary>
|
||||
private async Task Search()
|
||||
{
|
||||
_cursorStack.Clear();
|
||||
await FetchPage((null, null));
|
||||
}
|
||||
|
||||
private async Task PrevPage()
|
||||
{
|
||||
if (_cursorStack.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The top of the stack is the cursor of the page BEFORE the current one.
|
||||
var previousCursor = _cursorStack.Pop();
|
||||
await FetchPage(previousCursor);
|
||||
}
|
||||
|
||||
private async Task NextPage()
|
||||
{
|
||||
if (_nextCursor is not { } next)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Stepping forward: remember the current page's cursor so Previous can
|
||||
// return to it.
|
||||
_cursorStack.Push(_currentCursor);
|
||||
await FetchPage(next);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch one keyset page starting after <paramref name="cursor"/>.
|
||||
/// </summary>
|
||||
private async Task FetchPage(
|
||||
(DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor)
|
||||
{
|
||||
_loading = true;
|
||||
_listError = null;
|
||||
try
|
||||
{
|
||||
var request = new SiteCallQueryRequest(
|
||||
CorrelationId: Guid.NewGuid().ToString("N"),
|
||||
StatusFilter: NullIfEmpty(_statusFilter),
|
||||
SourceSiteFilter: NullIfEmpty(_siteFilter),
|
||||
ChannelFilter: NullIfEmpty(_channelFilter),
|
||||
TargetKeyword: NullIfEmpty(_targetFilter),
|
||||
StuckOnly: _stuckOnly,
|
||||
FromUtc: ToUtc(_fromFilter),
|
||||
ToUtc: ToUtc(_toFilter),
|
||||
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
|
||||
AfterId: cursor.AfterId,
|
||||
PageSize: PageSize,
|
||||
SourceNodeFilter: NullIfEmpty(_nodeFilter));
|
||||
|
||||
var response = await CommunicationService.QuerySiteCallsAsync(request);
|
||||
if (response.Success)
|
||||
{
|
||||
// CentralUI-028: drop any row whose source site is outside the
|
||||
// user's permitted set, as a row-level safety net behind the
|
||||
// dropdown restriction.
|
||||
_siteCalls = await FilterPermittedAsync(response.SiteCalls);
|
||||
_currentCursor = cursor;
|
||||
|
||||
// The response echoes the last row's cursor. A short page (fewer
|
||||
// rows than requested) has no further page even if a cursor came
|
||||
// back, so gate Next on a full page too.
|
||||
_nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated
|
||||
&& response.NextAfterId is { } nextId
|
||||
&& _siteCalls.Count == PageSize
|
||||
? (nextCreated, nextId)
|
||||
: null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_listError = response.ErrorMessage ?? "Query failed.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_listError = $"Query failed: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task RetrySiteCall(SiteCallSummary c)
|
||||
{
|
||||
// CentralUI-028: server-side re-check before relaying — a Retry relay must
|
||||
// not fire for a site outside the caller's permitted set, even if the row
|
||||
// somehow appeared in the grid.
|
||||
if (!await IsRowSiteAllowedAsync(c.SourceSite))
|
||||
{
|
||||
_toast.ShowError("You are not permitted to act on cached calls for this site.");
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Retry cached call",
|
||||
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||
$"to site {SiteName(c.SourceSite)}?");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var response = await CommunicationService.RetrySiteCallAsync(
|
||||
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
|
||||
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||
appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
|
||||
if (response.Success)
|
||||
{
|
||||
await RefreshAll();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Retry failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DiscardSiteCall(SiteCallSummary c)
|
||||
{
|
||||
if (!await IsRowSiteAllowedAsync(c.SourceSite))
|
||||
{
|
||||
_toast.ShowError("You are not permitted to act on cached calls for this site.");
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard cached call",
|
||||
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var response = await CommunicationService.DiscardSiteCallAsync(
|
||||
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
|
||||
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||
appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
|
||||
if (response.Success)
|
||||
{
|
||||
await RefreshAll();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Discard failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Surface a relay outcome on the toast — exactly one toast per relay
|
||||
/// response. The <see cref="SiteCallRelayOutcome.SiteUnreachable"/> case is
|
||||
/// deliberately distinct from a generic failure: the action was not applied
|
||||
/// but the operator can retry once the site is back online.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="SiteCallRelayOutcome"/> switch is exhaustive, so it owns
|
||||
/// the single toast. <paramref name="siteReachable"/> is a redundant
|
||||
/// cross-check on the same signal (the contract sets it <c>false</c> only
|
||||
/// for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>); it is folded
|
||||
/// INTO the <see cref="SiteCallRelayOutcome.OperationFailed"/> case rather
|
||||
/// than firing a second toast — an <c>OperationFailed</c> response that also
|
||||
/// reports an unreachable site shows the unreachable wording, once.
|
||||
/// </remarks>
|
||||
private void ShowRelayOutcome(
|
||||
SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage)
|
||||
{
|
||||
switch (outcome)
|
||||
{
|
||||
case SiteCallRelayOutcome.Applied:
|
||||
_toast.ShowSuccess(appliedMessage);
|
||||
break;
|
||||
case SiteCallRelayOutcome.NotParked:
|
||||
_toast.ShowInfo(errorMessage
|
||||
?? "The site reported nothing to do — the cached call is no longer parked.");
|
||||
break;
|
||||
case SiteCallRelayOutcome.SiteUnreachable:
|
||||
_toast.ShowError(errorMessage
|
||||
?? "Site unreachable — the relay did not reach the owning site. "
|
||||
+ "Try again once the site is back online.");
|
||||
break;
|
||||
case SiteCallRelayOutcome.OperationFailed when !siteReachable:
|
||||
// An OperationFailed response that nonetheless reports the site
|
||||
// unreachable: trust the reachability signal and show the
|
||||
// unreachable wording instead of the generic failure message.
|
||||
_toast.ShowError(errorMessage
|
||||
?? "Site unreachable — the relay did not reach the owning site. "
|
||||
+ "Try again once the site is back online.");
|
||||
break;
|
||||
case SiteCallRelayOutcome.OperationFailed:
|
||||
default:
|
||||
_toast.ShowError(errorMessage ?? "The site could not apply the action.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowDetail(SiteCallSummary c)
|
||||
{
|
||||
// The summary fields render immediately from the grid row; the full detail
|
||||
// (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
|
||||
_detailSiteCall = c;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await CommunicationService.GetSiteCallDetailAsync(
|
||||
new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId));
|
||||
if (response.Success && response.Detail != null)
|
||||
{
|
||||
_detail = response.Detail;
|
||||
}
|
||||
else
|
||||
{
|
||||
_detailError = response.ErrorMessage ?? "Failed to load site call detail.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_detailError = $"Failed to load site call detail: {ex.Message}";
|
||||
}
|
||||
_detailLoading = false;
|
||||
}
|
||||
|
||||
private void CloseDetail()
|
||||
{
|
||||
_detailSiteCall = null;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = false;
|
||||
}
|
||||
|
||||
private async Task RetryFromDetail(SiteCallSummary c)
|
||||
{
|
||||
await RetrySiteCall(c);
|
||||
// RefreshAll replaces the row list; close the modal so the user sees the
|
||||
// refreshed grid rather than a now-stale detail snapshot.
|
||||
CloseDetail();
|
||||
}
|
||||
|
||||
private async Task DiscardFromDetail(SiteCallSummary c)
|
||||
{
|
||||
await DiscardSiteCall(c);
|
||||
CloseDetail();
|
||||
}
|
||||
|
||||
private void ClearFilters()
|
||||
{
|
||||
_statusFilter = string.Empty;
|
||||
_channelFilter = string.Empty;
|
||||
_siteFilter = string.Empty;
|
||||
_targetFilter = string.Empty;
|
||||
_nodeFilter = string.Empty;
|
||||
_stuckOnly = false;
|
||||
_fromFilter = null;
|
||||
_toFilter = null;
|
||||
}
|
||||
|
||||
private bool HasActiveFilters =>
|
||||
!string.IsNullOrEmpty(_statusFilter) ||
|
||||
!string.IsNullOrEmpty(_channelFilter) ||
|
||||
!string.IsNullOrEmpty(_siteFilter) ||
|
||||
!string.IsNullOrEmpty(_targetFilter) ||
|
||||
!string.IsNullOrEmpty(_nodeFilter) ||
|
||||
_stuckOnly ||
|
||||
_fromFilter != null ||
|
||||
_toFilter != null;
|
||||
|
||||
private string SiteName(string siteId) =>
|
||||
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
|
||||
|
||||
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI-027: <c><input type="datetime-local"></c> binds with
|
||||
/// <see cref="DateTimeKind.Unspecified"/> and the value is the operator's
|
||||
/// browser-local wall-clock. Tag it <see cref="DateTimeKind.Local"/> and
|
||||
/// convert to UTC before the value enters the wire query — otherwise the
|
||||
/// From/To window is silently shifted by the operator's UTC offset.
|
||||
/// </summary>
|
||||
private static DateTime? ToUtc(DateTime? value) =>
|
||||
value.HasValue
|
||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
|
||||
: (DateTime?)null;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
|
||||
/// a <see cref="DateTimeOffset"/> for <c>TimestampDisplay</c>.
|
||||
/// </summary>
|
||||
private static DateTimeOffset? AsOffset(DateTime? value) =>
|
||||
value == null
|
||||
? null
|
||||
: new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc));
|
||||
|
||||
// A Guid's "N" format is always exactly 32 hex chars, so the [..12] slice is
|
||||
// always in range — no length guard needed.
|
||||
private static string ShortId(Guid id) => id.ToString("N")[..12];
|
||||
|
||||
private static string StatusBadgeClass(string status) => status switch
|
||||
{
|
||||
"Delivered" => "bg-success",
|
||||
"Parked" => "bg-danger",
|
||||
"Failed" => "bg-danger",
|
||||
"Attempted" => "bg-warning text-dark",
|
||||
"Forwarded" => "bg-info text-dark",
|
||||
"Submitted" => "bg-info text-dark",
|
||||
"Discarded" => "bg-secondary",
|
||||
_ => "bg-light text-dark"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Drops any site-call row whose source site resolves to a Site.Id outside the
|
||||
/// caller's permitted set. System-wide users get the list back unchanged.
|
||||
/// Lookup uses <c>_allSites</c> (not <c>_sites</c>) so a row whose Site was
|
||||
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
|
||||
/// </summary>
|
||||
private Task<List<SiteCallSummary>> FilterPermittedAsync(
|
||||
IEnumerable<SiteCallSummary> calls)
|
||||
{
|
||||
if (_siteScopeSystemWide)
|
||||
return Task.FromResult(calls.ToList());
|
||||
|
||||
var filtered = calls
|
||||
.Where(c =>
|
||||
{
|
||||
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == c.SourceSite);
|
||||
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
|
||||
})
|
||||
.ToList();
|
||||
return Task.FromResult(filtered);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
|
||||
/// user, or when the row's source site maps to a Site.Id in the permitted set.
|
||||
/// </summary>
|
||||
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
|
||||
{
|
||||
if (_siteScopeSystemWide)
|
||||
return Task.FromResult(true);
|
||||
|
||||
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
|
||||
if (resolved is null)
|
||||
return Task.FromResult(true);
|
||||
|
||||
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user