Skip to content

Add Contact Center module - #501

Open
MikeAlhayek wants to merge 339 commits into
mainfrom
ma/add-contact-center
Open

Add Contact Center module#501
MikeAlhayek wants to merge 339 commits into
mainfrom
ma/add-contact-center

Conversation

@MikeAlhayek

Copy link
Copy Markdown
Member

No description provided.

Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs Outdated
Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs Outdated
Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs Outdated
Comment thread src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs Outdated
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-actions

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve those before requesting a review.

Use the shared CrestApps bootstrap-select implementation throughout Contact Center, preserve bulk queue and campaign selection, and guard the resource contract with a regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Call out that existing ACS-enabled tenants may auto-enable the Azure Email and SMS provider features and require post-upgrade configuration review.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Pin the current session-blind routing behavior and missing server-swept after-call deadline so R3 can invert both invariants safely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: adb96b22-9043-4fb2-a5ac-79f3614af79d
MikeAlhayek and others added 30 commits August 3, 2026 01:14
Add a per-user, per-provider distributed lock around the Telephony OAuth
token-refresh path so a provider that rotates its refresh token on first
use can no longer lose the only valid replacement when several requests
find the access token expiring at once.

- Add TokenRefreshLockTimeout/TokenRefreshLockExpiration to
  TelephonyCoordinationOptions with startup validation ensuring the lease
  outlasts the wait window.
- DefaultTelephonyAuthenticationService refreshes under the lock, reloads
  the current user (ITelephonyUserAccessor.ReloadCurrentUserAsync ->
  ISession.Detach) so a peer's committed refresh is observed instead of
  the stale scoped copy, and commits durably (SaveChangesAsync) before
  releasing the lock.
- Commit failures degrade gracefully via TelephonyUserPersistenceException
  so the status probe never faults (consistent with OC-030).
- Add a hardened concurrency test proving two racing callers trigger
  exactly one provider refresh.

Independent gpt-5.6-sol review: APPROVE.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Asterisk and DialPad resilience pipelines retried on the standard
resilience handler regardless of HTTP method, so a lost response to a
call-origination POST could place a second outbound call and a retried
OAuth authorization-code or refresh-token POST could fail with
invalid_grant after the first request already succeeded. Both pipelines
now call options.Retry.DisableForUnsafeHttpMethods() so POST/PATCH/PUT/
DELETE/CONNECT are never auto-replayed while idempotent safe methods
still retry; neither provider exposes a deterministic idempotency key.

A source-scanning architecture guard (ProviderHttpRetryArchitectureTests)
dynamically discovers every source file under src that installs the
standard resilience handler and requires each to disable unsafe-method
retries (comment-stripped, per-invocation count). That guard surfaced the
AbstractAPI, Veriphone, and Twilio Lookup phone-number-verification
clients, which are GET-only today; the same fail-closed disable was
applied so a future non-GET request cannot silently gain unsafe retries.

Independent gpt-5.6-sol review: APPROVE (after hardening the guard from a
hardcoded two-file list to a dynamic, comment-stripped, per-invocation
count).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
DialerPacingBackgroundTask and AgentAvailabilityRecoveryBackgroundTask
each held a 60-second distributed lock while doing sequential work with
no matching run deadline. A run that exceeded the lease let a second node
begin an overlapping pacing cycle -- dropping the per-invocation cap that
constrains aggregate call rate -- or race agent-state transitions.

Both tasks now adopt the bounded-run pattern already proven in
ReservationExpiryBackgroundTask: LockExpiration is raised to 120_000 (2x
the one-minute schedule) and each run is bounded to a 90_000 ms wall-clock
budget strictly below the lock expiration, enforced by a linked
CancelAfter token that cancels in-flight work. DialerPacing additionally
checks a between-profile IClock.UtcNow deadline and defers remaining
profiles to the next tick. Shutdown cancellation is rethrown so the lease
releases promptly; budget cancellation is logged at Debug and deferred.

Tests: new DialerPacingBackgroundTaskTests (happy-path, quiescence,
clock-advance budget-defer, shutdown propagation, metadata ordering
guard) and updated AgentAvailabilityRecoveryBackgroundTaskTests (budgeted
linked-token assertion + metadata ordering guard). Also fixes a CA1875
warning in ProviderHttpRetryArchitectureTests (Regex.Count) so the test
project builds warning-clean under -warnaserror.

Independent gpt-5.6-sol review: APPROVE.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hboard accessible

The two real-time surfaces (agent workspace and supervisor dashboard) carried
no ARIA semantics: offers, presence, queue depth, and the active-call/summary/
board regions were injected via innerHTML into containers with no live-region
roles, the presence menu had no menu semantics or keyboard support, and failures
surfaced through blocking window.alert() dialogs. A blind or low-vision agent
received no announcement that a call was ringing.

Agent workspace:
- Incoming-offer container is role="alert" aria-live="assertive" aria-atomic and
  moves keyboard focus to Accept when a new offer renders.
- Active-interaction, queue-chip regions are aria-live="polite" with accessible
  names; the per-second countdown and talk-time nodes are aria-hidden so state is
  announced once rather than every tick.
- Presence control is a real ARIA menu: aria-haspopup/aria-expanded/aria-controls
  on the trigger, role="menu"/role="menuitem" children, first-item focus on open,
  Arrow/Home/End roving focus, Escape-to-close with focus return, click-away close.

Supervisor dashboard:
- Summary/tiles/board are aria-live="polite" with accessible names, and only
  re-render (and thus re-announce) when their content actually changes.

Both surfaces:
- Replace window.alert() with a non-blocking inline role="alert" error region.
- Add a role="status" connection indicator driven by new lifecycle callbacks
  (onConnected/onReconnecting/onDisconnected) surfaced from the shared
  contact-center-realtime helper, which now hooks connection.onreconnecting and
  reports connect/close transitions. setConnectionStatus is idempotent so a
  restored connection announces "Connected" exactly once.
- All new user-facing strings are localizable via the existing config dictionaries.

Corrected the false "W6 agent-desktop accessibility completed" claim in
.github/contact-center/PRODUCTION-READINESS.md to describe the actually-delivered
agent-and-supervisor accessibility work (closes OC-038). Independent gpt-5.6-sol
review: APPROVE.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The assignment loop and the voice/generic offer paths each materialized an
entire queue's waiting backlog via QueueItemStore.ListWaitingAsync only to
pick a single winner through QueueItemPrioritizer.SelectNext, producing
roughly quadratic query traffic and allocations under a queue spike.

Add a bounded IQueueItemStore.FindNextWaitingAsync (FirstOrDefaultAsync with
the same Priority-desc, EnqueuedUtc-asc ordering as ListWaitingAsync) and
IQueueItemManager.FindNextWaitingAsync(queue, utcNow). The manager routes to
the bounded query whenever the queue does not apply SLA aging -- where an
item's effective priority equals its base priority, so the store's first row
is provably identical to SelectNext -- and falls back to the full in-memory
scan only for queues that opt into SLA aging (wait-time reordering requires
scoring every candidate). ListWaitingAsync is retained for the aging fallback
and for OverflowDueAsync, which legitimately walks the whole backlog.

Update the three call sites and the affected mocks; add QueueItemManagerTests
proving the fast path uses only the bounded store query and the aging path
scores the backlog. Update the ContactCenter.Core public API baseline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The supervisor dashboard refreshes every 10 seconds and previously issued
several queries per queue and three queries per agent (active interaction,
active count, and a user lookup for the display name), plus a per-queue and
per-busy-agent supervisor authorization lookup and a per-agent monitoring-mode
reload. A few hundred watched agents produced thousands of DB round-trips per
minute per supervisor, competing with the routing hot paths.

The poll now resolves the whole scoped set in fixed batches:
- waiting depth via the existing batched CountWaitingByQueueIdsAsync
- active interactions via a new index-backed ListActiveByAgentIdsAsync
  (chunked .IsIn query, most-recent-by-CreatedUtc per agent)
- active counts via the existing CountActiveByAgentIdsAsync
- display names by bulk-loading users with one chunked UserId.IsIn query and
  feeding each materialized user to IDisplayNameProvider.GetAsync (no further DB)

Supervisor queue authorization is memoized per queue for the request, and a new
GetAvailableModesAsync(Interaction) overload resolves engagement modes from the
already-batched interaction instead of reloading it via FindByIdAsync. No new
raw SQL was added, so the query-plan budget gate is unaffected. Remaining
per-queue longest-wait/SLA reads are bounded residuals (queues are far fewer
than agents).

Adds ListActiveByAgentIdsAsync to IInteractionStore/IInteractionManager and
their default implementations, and GetAvailableModesAsync(Interaction) to
IContactCenterMonitoringService. Independently reviewed (gpt-5.6-sol: APPROVE).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The encrypted recording media store previously protected the full recording
as one byte[] on write and, on read, copied the file into a MemoryStream,
materialized it to an array, unprotected the whole array, and returned another
MemoryStream -- several times the recording size in managed memory and LOH
pressure. The Asterisk download side pulled the stored file into a byte array
before handing it over.

Recording media now streams end to end through a chunked authenticated-
encryption container (RecordingMediaCryptoFormat + ChunkedAeadEncryptingReadStream
/ ChunkedAeadDecryptingReadStream). Envelope encryption: a per-recording random
AES-256-GCM data key encrypts the media as independently authenticated 64 KiB
frames, and that data key is wrapped by the data-protection provider, so key
management (tenant isolation, rotation) stays with data protection while bulk
media flows a fixed chunk at a time in both directions. Every frame binds its
ordinal counter, length, and an end-of-stream marker into the AES-GCM associated
data, so tampering, reordering, truncation, and trailing data are rejected on
read (CryptographicException).

RecordingMediaWriteRequest.Content changes from byte[] to Stream; the store
reads and writes straight through IFileStore without a whole-recording copy.
AsteriskAriClient.DownloadStoredRecordingAsync uses ResponseHeadersRead and
returns an owning AsteriskAriStoredRecordingContent whose stream is await
using-scoped across the store call; an empty chunked body (no Content-Length)
is detected by a one-byte peek replayed through LeadingByteStream. The store's
encrypting stream honors the operation cancellation token on every source read.

Recording is off by default and the media format is new, so there is no stored
media to migrate. Added multi-chunk, empty, tamper, truncation, and trailing-
data tests; updated the public API baseline. Independently reviewed (gpt-5.6)
and approved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reservation cleanup previously loaded every expired pending reservation
in a single query and reporting materialized whole date ranges with no
upper bound, so a spike or a wide report window could pull an unbounded
result set into memory.

Reservation cleanup now drains the expiry backlog in bounded, oldest-first
pages using keyset (seek) paging over the stable (ExpiresUtc, DocumentId)
order. ListExpiredAsync takes a keyset cursor and returns an
ExpiredReservationPage; because the cursor is an absolute position rather
than a numeric offset, concurrent expirations or insertions never shift the
window, so a live reservation is never skipped and a locked oldest page
never starves the drainable reservations behind it. Locked candidates are
retried on the next scheduled sweep.

Reporting now fails fast on an over-wide window instead of silently
trimming rows (which would corrupt aggregate totals): a new
ContactCenterReportingOptions.MaximumReportRange (bound from
CrestApps_ContactCenter:Reporting, default 400 days, validated on start)
is enforced by a shared guard at the top of every report query path,
including the enterprise report providers.

This changes the ListExpiredAsync signature on IActivityReservationStore
and IActivityReservationManager and adds an IOptions<ContactCenterReportingOptions>
constructor parameter to ContactCenterReportingService.

Adds unit tests for multi-page draining and the anti-starvation case, a
real-store integration test proving the keyset query pages in stable order
against SQLite, and reporting range-guard tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nistration (OC-006)

Both provider modules registered their site settings display driver in the
un-attributed base Startup, even though the driver's SettingsGroupId
(TelephonyConstants.SettingsGroupId) only has an admin menu entry contributed
by the Telephony Administration feature. This gave headless deployments admin
drivers they did not want, and — when the provider was enabled without Telephony
Administration — a settings editor with no navigation entry reachable only by
guessing the groupId URL.

Move each AddSiteDisplayDriver call into a dedicated
[RequireFeatures(TelephonyConstants.Feature.Admin)]-gated AsteriskAdminStartup /
DialPadAdminStartup class so the provider settings tab and its telephony-group
navigation entry always appear together.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Contact Center catalog and entitlement POST actions carry no explicit
antiforgery attribute; they rely on Orchard Core's globally registered
AutoValidateAntiforgeryToken filter. That reliance was assumed rather than
asserted.

Add AdminPostAntiforgeryArchitectureTests, a reflection-based architecture test
that enumerates every concrete MVC controller in the ContactCenter, Telephony,
Asterisk and DialPad module assemblies, treats every POST-capable action (an
unsafe verb constraint, an [AcceptVerbs] provider, or no verb constraint at all)
as in scope, and asserts none opts out of antiforgery via [IgnoreAntiforgeryToken]
on the action or its controller. Because the only way a cross-site POST could
bypass the global filter is an explicit opt-out, proving no opt-out exists proves
the coverage holds. A companion test pins the known catalog controllers so the
scan can never silently degrade to zero actions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…elephony async paths

Cancellation was handled as an ordinary fault across the ContactCenter/Telephony/
Asterisk/DialPad async paths, and the Asterisk realtime listener accepted unbounded
WebSocket frames and could stall on a hostile peer's close handshake.

- Add `catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }`
  guards before broad generic catches in DialPadTelephonyProvider (6 sites),
  ContactCenterOutbox, and TelephonyInteractionReconciliationBackgroundTask so
  host/shutdown cancellation is never masqueraded as a provider failure.
- Introduce tri-state AsteriskAgentChannelReadyOutcome (Ready/NotReady/Canceled)
  replacing the bool WaitAsync result; move the cancellation-precedence check to run
  immediately after Task.WhenAny so a simultaneous supersede(false)+cancel is not
  misreported as NotReady. Update Conference/Transfer/Monitoring callers to throw OCE
  on Canceled instead of returning a false no-answer disposition.
- Restructure the AsteriskContactCenterVoiceProvider connect path with a dedicated OCE
  catch that compensates using the `bridgeCreateAttempted || originateAttempted`
  ambiguity flag (mirroring the generic catch's OutcomeUnknown), RETAINING the durable
  binding record so the age-gated reconciler can reclaim a late-committing resource.
- Cap accumulated realtime message size (MaxRealtimeMessageBytes, default 1 MiB,
  ceiling 64 MiB, validated at startup) and add CloseSocketSafelyAsync using
  CloseOutputAsync under a bounded 5s token for both MessageTooBig and NormalClosure
  paths, so a hostile peer cannot stall the close indefinitely.
- Split the two challenged Won't-Fix sub-parts into tracked items OC-050 (binding-store
  bounded/cancellable locking) and OC-051 (MustComplete bounded shutdown seam).
- Add enum + Canceled readiness tests and a cancellation-during-bridge-create regression
  test asserting OCE propagation and record retention.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nter modules

The repository conventions require a README.md for every module, but none of
ContactCenter, Telephony, Asterisk, or DialPad had one.

Adds a top-level README.md to each module documenting its purpose, a feature
table with feature IDs verified against the module manifests and *Constants
classes, a recipe-based installation snippet, configuration, usage,
dependencies, and a link to the documentation site:

- Telephony: provider-agnostic call layer, hub, soft phone, and provider
  authoring guidance.
- Asterisk: provider, ARI/real-time listener, Contact Center voice/media
  adapters, and the single-active-process ARI ownership deployment constraint.
  Configuration section distinguishes the admin settings screen fields from the
  CrestApps:Asterisk:Coordination shell-configuration options.
- DialPad: provider and Contact Center voice boundary; configuration lists the
  actual editor fields (environment, API token or OAuth credentials, caller id,
  user id, webhook signing secret).
- ContactCenter: grouped feature tables for the full capability set, the five
  actual *.Admin features, and an installation recipe that enables the admin
  features the example needs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ocalized resource

The approved external-transfer destinations editor
(ContactCenterExternalTransferSettings.Edit.cshtml) rendered a ~57-line inline
<script> block whose dynamically added rows hardcoded the "Display name"
placeholder and "Remove destination" button title in English, so added rows were
never localized and those strings were invisible to localization extraction.

- Extract the add/remove/renumber logic into a Gulp-built asset,
  Assets/js/contact-center-external-transfer-settings.js (minified output under
  wwwroot/scripts), and register it in Assets.json.
- Register it as the named resource contact-center-external-transfer-settings via
  a new ContactCenterExternalTransferResourceConfiguration, wired into the Contact
  Center Administration feature startup that already registers the settings driver.
- Require the script with at="Foot" and pass a localized `strings` config object
  (displayName, removeDestination) through a data-config attribute, mirroring the
  AgentWorkspace/SupervisorDashboard pattern, so dynamically added rows honor the
  active culture. The row template HTML-attribute-encodes the injected strings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…API (OC-013)

Move five storage/schema/projection scalars (CollectionName,
CurrentEventSchemaVersion, MetricsProjectionHandlerId, MetricsProjectionVersion,
ProviderNameLength) out of the public ContactCenterConstants in the Abstractions
package into a new internal ContactCenterStorage class in ContactCenter.Core, so
bumping an internal projection version or schema revision no longer forces a
public-package version bump for consumers that only depend on the webhook and
event contracts. The five constants keep the same CrestApps.OrchardCore.ContactCenter
namespace, so the ~430 mechanical reference updates across Core/Module/Tests/
DistributedTests need no new usings. Core exposes its internals to the module and
distributed-test assemblies via InternalsVisibleTo.

The manual-call aggregate-type discriminator stays public: it is emitted as
InteractionEvent.AggregateType on the published ManualDialSuppressed event and is
part of the event contract webhook/workflow consumers may inspect. It moved into a
new public ContactCenterConstants.AggregateTypes group, matching how the other
event-contract discriminators (Events.*, Components.*) are grouped. SystemActor and
the diagnostic Components taxonomy also remain public.

Regenerate the ContactCenter.Abstractions and .Core public-API baselines to reflect
the reduced surface and the two new InternalsVisibleTo grants.

Also fix a pre-existing ContactCenterFeatureDependencyArchitectureTests failure
introduced by OC-006's AsteriskAdminStartup: that startup carries [RequireFeatures]
but no [Feature], so the test's parser maps it to the base Asterisk feature id and
the base-startup .Single() lookup became ambiguous. Disambiguate by selecting the
ungated base startup (RequiredFeatureIds.Count == 0).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the tenant-wide logout middleware (matched on two hardcoded
account URLs) with an IPostConfigureOptions<CookieAuthenticationOptions>
that chains the application cookie scheme's OnSigningOut and
OnValidatePrincipal events, so agent presence sign-out and soft-phone
credential revocation run whenever the cookie session ends by any
mechanism, including external front-channel sign-out and security-stamp
rejection.

OnValidatePrincipal captures the user id before the prior handler and
synchronizes only when the principal is rejected, covering the
security-stamp case where OnSigningOut fires before HttpContext.User is
populated. The synchronization body is isolated from the sign-out flow:
all exceptions are swallowed and a bounded, request-independent
CancellationTokenSource replaces RequestAborted, so a slow or failing
cleanup can never abort logout and leave a live auth cookie.

Centralize the per-revoker revocation loop into a shared
internal SoftPhoneCredentialRevocation helper in Core, and call it from
AgentSessionService.ExpireStaleAsync so the cleanup backstop revokes
browser SIP credentials for pure cookie-expiry sessions too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…er Administration

The ContactCenter module declared five per-capability administration features
(Agents.Admin, Queues.Admin, Dialer.Admin, Recording.Admin, EntryPoints.Admin),
each depending on both the root Admin feature and its capability feature. None
could ever be enabled in isolation, so they added state-space complexity with no
independent activation value; several had one- or two-line startup bodies.

Fold all five into the single Contact Center Administration feature. Their admin
StartupBase classes and the eight admin controllers now carry
[Feature(Admin)] + [RequireFeatures(<capability>)], so every administration
registration belongs to the single Admin feature and each capability's screens
light up only when both Admin and that capability are enabled. This is verified
equivalent to the old design: OrchardCore's CompositionStrategy skips any
exported type (startup or controller) whose [RequireFeatures] set is not fully
enabled, and RequireFeaturesAttribute returns 404 for a routed admin controller
whose capability is disabled.

- Manifest.cs: removed 5 .Admin Feature declarations (24 -> 19 features)
- ContactCenterConstants.cs: removed 5 Feature.<Capability>Admin constants
- Startup.cs + 8 controllers: [Feature(Admin)] + [RequireFeatures(<capability>)]
- feature-dependency-violations.v1.json: removed 5 admin closure blocks
- ContactCenterHeadlessClosureTests: trimmed _userExperienceFeatures; replaced
  the obsolete per-.Admin-feature test with two proofs (Admin+capability
  registers strictly more surface than Admin alone; capability without Admin
  registers no surface)
- ContactCenterFeatureDependencyArchitectureTests: entry-point nav owner is now
  Admin requiring EntryPoints
- Regenerated Abstractions public-API baseline; updated README, docs, plan,
  changelog

Builds 0 warnings; ContactCenter unit (1372), FeatureActivation (58),
PublicApi (33), architecture tests all pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-032)

Provider revocation failures were previously swallowed while the local tokens
were deleted as if revocation had succeeded, leaving an external grant active
with no retained state or signal to the caller.

- ITelephonyAuthenticationProvider.RevokeTokensAsync now returns a typed
  TelephonyResult: confirmed Success (2xx / nothing to revoke), definitive
  Failed (non-ambiguous 4xx), or indeterminate Unknown (timeout 408,
  throttling 429, 5xx, or transport error) because the unsafe deauthorize
  POST may still have committed. Reuses the existing IsAmbiguousStatusCode
  classifier. DialPad attempts revocation whenever a stored access token
  exists, regardless of the current authentication mode, so a switch to
  API-key auth cannot abandon a live OAuth grant.
- ITelephonyAuthenticationService.DisconnectAsync now returns that result. It
  removes the local tokens first and durably commits the deletion via
  SaveChangesAsync before the remote revocation, so the credentials are
  cleared immediately, concurrent scopes cannot observe stale tokens, and a
  canceled/failing remote call cannot leave the local tokens behind. When a
  live token exists but no per-user authentication provider is available to
  revoke it, it returns Unknown instead of a false Success. Non-confirmed
  revocation is logged with the provider name and reason.
- TelephonyOAuthController.Disconnect relays the outcome as
  remoteRevocationConfirmed: false with the reason when the grant could not
  be confirmed revoked.

Adds tests covering Success/Failed/Unknown per status code, revocation after a
switch to API-key mode, local-token clearing on failure/exception, and the
Unknown-on-missing-provider branch. Regenerates the Telephony and
Telephony.Abstractions public-API baselines. Updates the plan, changelog, and
dialpad docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mination

Bounds the channel-binding create-lock acquisition and makes the
stranded-caller termination fence survive shell reloads so a late or
reload-abandoned ARI hang up can never terminate a call a new shell
generation has routed.

- AsteriskChannelTenantBindingStore.CreateAsync now uses a bounded
  SemaphoreSlim.WaitAsync(timeout) (AsteriskCoordinationOptions
  .ChannelBindingCreateLockTimeout, default 10s) and throws
  AsteriskChannelBindingCreateTimeoutException on acquisition timeout
  instead of masquerading as a lost create race. Read-only lookups
  thread CancellationToken.
- AsteriskPendingCallerTerminationRegistry holds BOTH the termination
  claim set and the pending-retry set as process-wide static,
  tenant-partitioned state (mirroring the static striped create locks),
  so the fence outlives a shell reload until termination completes and
  no claim leaks.
- AsteriskPendingCallerTerminationReconciler re-claims each pending
  channel before hanging up: claim-lock timeout keeps it pending, a
  refused claim (binding recovered) resolves it out without a hang up,
  a granted claim hangs up outside all locks then releases + resolves.
- AsteriskInboundCallOfferBridge enqueues the channel before inline
  termination so a reload mid-hang-up leaves it owned by the reconciler.

Independently reviewed (gpt-5.6) across nine rounds; final APPROVE.
Build 0 warnings; 715 Telephony tests pass (1 skipped browser E2E).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ration (OC-026)

Enabled queues, skills, business-hours calendars, and entry points are small,
slowly-changing collections read on the hot inbound-routing path, yet each read
issued a fresh Session.Query. Introduce IContactCenterConfigurationCache, a
per-tenant shell-scoped-state cache (ConcurrentDictionary snapshot guarded by an
ISignal change token), and delegate the four managers' ListEnabledAsync to it.

A generic ContactCenterConfigurationCacheInvalidationHandler<T> registered
alongside the existing catalog handlers signals the change token on
Created/Updated/Deleted via ShellScope.AddDeferredTask, so invalidation fires
after the writing transaction commits. The token is captured before each load so
a write landing mid-load forces the next read to reload rather than serve a stale
snapshot. Uses shell-scoped state + ISignal instead of IMemoryCache to comply
with the Contact Center architecture guard, and is honored across process
instances sharing the tenant's signal backplane.

Also fixes a latent xUnit1051 in the OC-050 AsteriskChannelTenantBindingIsolationTests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
escapeHtml was copy-pasted into four scripts, formatDuration/pad duplicated, and
the ordered call-state name list — which must match the C# CallState enum
position for position — was hard-coded in both soft phones, so the copies could
drift from each other and from the enum unnoticed.

Add a shared telephony-client resource in the Telephony base module exposing
escapeHtml, formatDuration, normalizeCallState, and callStateNames on
window.telephonyClient. telephony-soft-phone and contact-center-realtime now
depend on it, so all four consumers reach one definition of each helper. A
build-time guard test (CallStateNamesJsSyncTests) asserts the shared script's
CALL_STATE_NAMES equals CallState in ordinal order and that the enum is a
contiguous zero-based sequence, so JS/C# drift fails the build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t views

The queues, skills, inbound entry points, dialer profiles, queue groups, agent
state reason codes, and business-hours calendars index views were seven ~57-line
files that differed only by title, create-label, list id, and empty-state message,
with the action bar, search field, item-count row, list markup, no-results alert,
and pager copied verbatim into each.

That shared markup now lives once in Views/Shared/_CatalogList.cshtml (resolved for
every controller in the module via OrchardCore's SharedViewLocationExpanderProvider),
backed by new CatalogListViewModel/CatalogListEntry view models. Each index view now
only builds a CatalogListViewModel and renders the partial, dropping from ~57 to ~22
lines. Per-type T["..."] literals stay in the individual views so localization
extraction still sees each screen's strings, while the shared strings collapse from
seven copies to one. The asp-for="Options.Search" binding is preserved via the
Options property on the view model.

Module and test project build with 0 warnings; all 1497 ContactCenter tests pass;
docs site builds clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The module's Startup.cs declared 36 StartupBase types in 1,298 lines — the one
place in the module set breaking the repository's one-public-type-per-file rule
and making a single feature's registrations hard to locate.

Each type now lives in its own file named for the class (VoiceStartup.cs,
RoutingStartup.cs, ComplianceStartup.cs, AnalyticsStartup.cs, the
*AdminStartup/*DeploymentStartup/*RecipesStartup files, etc.), carrying only the
using directives it needs. Startup.cs keeps just the base Startup feature class.
No type, namespace, attribute, XML doc, or service registration changed, so the
split is behaviour-preserving.

Two source-parsing architecture tests that assumed a single Startup.cs were
updated to scan the whole module: ContactCenterFeatureDependencyArchitectureTests
now uses ParseStartupClassesInDirectory at its twelve Contact Center call sites,
and ContactCenterRetentionCoverageTests concatenates every *Startup.cs before
asserting each retention policy is registered.

Module and test project build 0 warnings; all 1497 ContactCenter tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Split the 793-line ContactCenterConstants.cs in the ContactCenter
abstractions into seven domain-scoped partial-class files. The single
file held fifteen nested constant groups (aggregate types, health
checks, feature ids, component names, recording metadata and its
governance/erasure reason codes, call-control metadata, event names,
and settings), so unrelated concerns shared one screen.

ContactCenterConstants is now a public static partial class whose
members are grouped into ContactCenterConstants.cs, .Features.cs,
.HealthChecks.cs, .Recording.cs, .CallControl.cs, .Events.cs, and
.Settings.cs. Because partial classes merge to identical metadata, the
public API is byte-for-byte unchanged: all fifteen groups and 120
constants are preserved, PublicApiApprovalTests pass with no baseline
change, and no consumer required edits. Purely a navigability
improvement with zero behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address six small correctness and consistency nits:

- ContactCenter Manifest.cs: replace the two remaining string-literal
  feature dependencies with the OmnichannelConstants.Features.Managements
  and SignalRConstants.Feature.Area constants.
- Move the outlier Telephony admin menu from Navigation/ to Services/ to
  match the repository-wide convention.
- Remove three illustrative provider names from provider-agnostic XML
  docs on TelephonyConstants.AuthenticationSchemes and
  IProviderIdentityResolver.
- TelephonyOAuthController: flow HttpContext.RequestAborted into the
  authentication-service calls it awaits.
- AsteriskContactCenterVoiceMediaProvider: dispose the HttpRequestMessage
  built for each REST call via a using scope.
- AsteriskContactCenterVoiceMediaSession: make DisposeAsync idempotent,
  stop disposing the two SemaphoreSlim locks (they hold no unmanaged
  handle, matching the sibling Asterisk primitives) to remove the
  teardown race, and release the feature work lease exactly once while
  preserving the retry-on-cleanup-failure contract and guaranteeing
  release on terminal disposal.

Generalise the manifest-token architecture test to read every
ContactCenterConstants*.cs partial file so it stays correct after the
OC-047 partial-class split.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record the disposition for OC-009. The four telephony/contact-center
modules set default shape placement in their display drivers rather than
in a placement.json, which is correct: Orchard Core resolves placement
globally by shape type, so a site or theme can already override or hide
any of these shapes with no code, and the driver .Location() calls are
sensible, fully overridable defaults.

Neither form of the proposed change adds value: a module placement.json
duplicating the 52 driver defaults would violate DRY with no behavioural
benefit, and migrating the defaults into placement.json (deleting the
.Location() calls) would be single-source but adds regression risk
across all 52 placements for no functionality. No specific beneficial
override was identified, so no change is made.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document the single-active-process constraint for Asterisk real-time voice
at the point of use. Asterisk delivers each Stasis event to exactly one ARI
application consumer, and the module arbitrates ownership of each
(base URL, application name) pair through a process-wide (node-local)
registry that cannot see a cross-node claim. Real-time voice is therefore
supported only on a single active application process, and running exactly
one active node is an operator responsibility.

Add a "Single active process per ARI application" section to the Asterisk
provider docs that is precise about enforcement: the only production-certified
topology is single-node-distributed and the multi-node profile is not certified,
but the Contact Center topology health check only validates a declared profile's
infrastructure prerequisites (declared-profile-required-in-production, database
provider, Redis distributed-lock/SignalR backplane) and does NOT count running
nodes -- so two hosts can each declare the single-node profile and both report
healthy. Operators must guarantee single-process operation through deployment.

Add a matching <remarks> block to AsteriskAriApplicationOwnershipRegistry and a
changelog bullet. No code behavior changed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
OC-015 flagged optional cross-feature services injected as IEnumerable<T> and
reduced with FirstOrDefault(). Re-evaluated the premise: this is the sanctioned
Orchard Core idiom for consuming a service that lives in a feature which may be
disabled -- when the feature is off the tenant container holds no registration,
so a direct ctor param would throw at activation. The pattern is applied
uniformly across ~19 sites and degrades safely (already covered by an existing
Compliance/Dialer test).

Both recommended alternatives regress: splitting central orchestrators along a
feature seam scatters one cohesive workflow across two classes for no
correctness benefit; null-object defaults make an optional dependency look
mandatory while silently no-opping, require ~10 no-op implementations kept in
lockstep with their interfaces, and change nothing about correctness. Documented
the Won't-Fix rationale in the readiness plan. No code changed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The provider webhook rate and concurrency limiters use in-process
System.Threading.RateLimiting, so limits are enforced per application node.
Document these semantics rather than build a distributed quota that only a
future certified multi-node topology would need.

Add a "Rate and concurrency limits are per node" subsection to the Provider
webhook ingress section of production-support.md: on the only production-
certified topology (a single application node) per-node equals the effective
global limit and no distributed quota is required to make it fleet-wide; edge
protection (WAF/reverse proxy/API gateway) remains the primary DoS control on
every topology, single-node included; and any multi-node fleet-wide ceiling
must be enforced at the edge since the in-process limiter cannot coordinate one.
Add matching <remarks> blocks to ProviderWebhookIngressLimiter and
ProviderWebhookIngressOptions. A Redis-backed global quota is recorded as a
tracked option for a future certified multi-node topology and deliberately
deferred. No behavior changed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add two provider-aligned, one-step feature-enablement recipes that stand up
the certified GA-Core Contact Center feature set without manual toggling:

- contact-center-asterisk-ga-core.recipe.json  (ga-core-asterisk profile)
- contact-center-dialpad-ga-core.recipe.json   (ga-core-dialpad profile)

Each recipe enables exactly the 11 features of its support-matrix tenant
profile. This intentionally deviates from the plan's illustrative
inbound/outbound split: the authoritative support ledger
(.github/contact-center/support-matrix.v1.json) certifies only the two
provider bundles, and its prohibitedCombinations forbid unlisted feature
combinations, so an inbound/outbound-only recipe would guide operators into
an uncertified (prohibited) state.

Recipes are feature-only (no seed content) because queues, skills, dialer
profiles, and entry points reference environment-specific provider endpoints
and campaigns. Harvested via OrchardCore.Module.Targets Recipes/** and
surfaced under Configuration -> Recipes.

Add ContactCenterSetupRecipeTests binding each recipe's feature set to the
ledger via set-equality and asserting every supported provider profile has a
recipe. Update configuration-deployment docs, changelog, and the readiness
plan (OC-010 -> Completed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace 54 hand-written report service registrations in
AnalyticsStartup (37 enterprise, 12 workforce, 5 individual — many over
400 characters) with a data-driven, extensible catalog.

Reports framework:
- Add IReportProvider to Reports.Abstractions: one registration
  contributes a family of reports via GetReports().
- ReportManager now merges reports from both individual IReport services
  and IReportProvider instances, still enforcing globally unique names.
  The original single-argument constructor is retained as a delegating
  overload, so this is not a source/binary break.

Contact Center:
- Move the 37 enterprise and 12 workforce report definitions into a
  configurable ContactCenterReportCatalogOptions, populated through the
  options pipeline by ConfigureContactCenterReportCatalog (keeping the
  S["..."] literals for localization extraction and deferring
  localization to request culture).
- Project the catalog through one scoped ContactCenterReportProvider that
  constructs a fresh report instance per enumeration, preserving the
  per-request-scope concurrency guarantee.
- AnalyticsStartup now registers the 5 individual reports, the configure
  options, and the provider; the 400-character registration lines and the
  imperative helpers are gone, and any feature can extend the catalog
  through options without editing service registration.

Tests: add ReportManager provider-aggregation and collision cases; update
the report concurrency test to assert IReport and IReportProvider
descriptors are scoped. Document IReportProvider in the Reports framework
docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants